diff --git a/README.md b/README.md
index 9422f4f..98dcc13 100644
--- a/README.md
+++ b/README.md
@@ -32,3 +32,5 @@ To do a prod build (with AOT):
- `npm run build`
The build artifacts will be stored in the `dist/` directory.
+
+
diff --git a/karma.conf.js b/karma.conf.js
index f9d2085..b538b22 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -42,8 +42,8 @@ module.exports = function (config) {
restartOnFileChange: true,
// not strictly required for testing but useful when debugging the grid in action
files: [
- '../node_modules/@ag-grid-community/styles/ag-grid.css',
- '../node_modules/@ag-grid-community/styles/ag-theme-balham.css'
+ './node_modules/@ag-grid-community/styles/ag-grid.css',
+ './node_modules/@ag-grid-community/styles/ag-theme-balham.css'
]
});
};
diff --git a/package.json b/package.json
index 81a6cf1..9054239 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ag-grid-angular-cli-example",
- "version": "31.0.2",
+ "version": "31.1.0",
"description": "AG Grid Angular Example Using Angular CLI",
"license": "MIT",
"repository": {
@@ -12,24 +12,26 @@
"start": "NODE_OPTIONS=--openssl-legacy-provider ng serve --port 8080",
"build": "NODE_OPTIONS=\"--openssl-legacy-provider --max-old-space-size=8192\" ng build --aot",
"build-prod": "npm run build",
- "lint": "tslint \"src/**/*.ts\""
+ "lint": "tslint \"src/**/*.ts\"",
+ "test:e2e": "NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/ng test --watch false --browsers ChromeHeadless",
+ "test:watch": "NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/ng test --watch true --browsers ChromeHeadless"
},
"private": true,
"dependencies": {
- "@ag-grid-community/angular": "~31.0.2",
- "@ag-grid-community/core": "~31.0.2",
- "@ag-grid-community/client-side-row-model": "~31.0.2",
- "@ag-grid-enterprise/core": "~31.0.2",
- "@ag-grid-enterprise/menu": "~31.0.2",
- "@ag-grid-enterprise/side-bar": "~31.0.2",
- "@ag-grid-enterprise/column-tool-panel": "~31.0.2",
- "@ag-grid-enterprise/filter-tool-panel": "~31.0.2",
- "@ag-grid-enterprise/row-grouping": "~31.0.2",
- "@ag-grid-enterprise/set-filter": "~31.0.2",
- "@ag-grid-enterprise/status-bar": "~31.0.2",
- "@ag-grid-enterprise/range-selection": "~31.0.2",
- "@ag-grid-enterprise/charts": "~31.0.2",
- "@ag-grid-community/styles": "~31.0.2",
+ "@ag-grid-community/angular": "~31.1.0",
+ "@ag-grid-community/core": "~31.1.0",
+ "@ag-grid-community/client-side-row-model": "~31.1.0",
+ "@ag-grid-enterprise/core": "~31.1.0",
+ "@ag-grid-enterprise/menu": "~31.1.0",
+ "@ag-grid-enterprise/side-bar": "~31.1.0",
+ "@ag-grid-enterprise/column-tool-panel": "~31.1.0",
+ "@ag-grid-enterprise/filter-tool-panel": "~31.1.0",
+ "@ag-grid-enterprise/row-grouping": "~31.1.0",
+ "@ag-grid-enterprise/set-filter": "~31.1.0",
+ "@ag-grid-enterprise/status-bar": "~31.1.0",
+ "@ag-grid-enterprise/range-selection": "~31.1.0",
+ "@ag-grid-enterprise/charts": "~31.1.0",
+ "@ag-grid-community/styles": "~31.1.0",
"@angular/animations": "^14.3.0",
"@angular/common": "^14.3.0",
"@angular/compiler": "^14.3.0",
@@ -47,8 +49,10 @@
"@angular-devkit/build-angular": "^14.2.12",
"@angular/cli": "^14.2.12",
"@angular/compiler-cli": "^14.3.0",
+ "@testing-library/angular": "^12.3.0",
+ "@testing-library/user-event": "^14.5.2",
"@types/jasmine": "~3.8.0",
- "@types/node": "^12.11.1",
+ "@types/node": "18.19.10",
"jasmine-core": "~3.8.0",
"karma": "~6.3.0",
"karma-chrome-launcher": "~3.1.0",
diff --git a/src/app/rich-grid-example/rich-grid.component.html b/src/app/rich-grid-example/rich-grid.component.html
index 2031572..2bcf09b 100644
--- a/src/app/rich-grid-example/rich-grid.component.html
+++ b/src/app/rich-grid-example/rich-grid.component.html
@@ -15,8 +15,8 @@
Rich Grid Example
Column API:
- Hide Country Column
- Show Country Column
+ Hide Country Column
+ Show Country Column
diff --git a/src/test.ts b/src/test.ts
index 6be4592..0dcbaa7 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -1,32 +1,28 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
-import 'zone.js/dist/long-stack-trace-zone';
-import 'zone.js/dist/proxy.js';
-import 'zone.js/dist/sync-test';
-import 'zone.js/dist/jasmine-patch';
-import 'zone.js/dist/async-test';
-import 'zone.js/dist/fake-async-test';
-import {getTestBed} from '@angular/core/testing';
-import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing';
+import 'zone.js';
+import 'zone.js/testing';
+import { getTestBed } from '@angular/core/testing';
+import {
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting
+} from '@angular/platform-browser-dynamic/testing';
-// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
-declare var __karma__: any;
-declare var require: any;
-
-// Prevent Karma from running prematurely.
-__karma__.loaded = function () {
+declare const require: {
+ context(path: string, deep?: boolean, filter?: RegExp): {
+ keys(): string[];
+ (id: string): T;
+ };
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
- BrowserDynamicTestingModule,
- platformBrowserDynamicTesting(), {
- teardown: { destroyAfterEach: false }
-}
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting(),
+ { teardown: { destroyAfterEach: true } },
);
+
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
-context.keys().map(context);
-// Finally, start Karma to run the tests.
-__karma__.start();
+context.keys().map(context);
\ No newline at end of file
diff --git a/src/tests/ag-grid-angular.spec.ts b/src/tests/ag-grid-angular.spec.ts
new file mode 100644
index 0000000..ae620b4
--- /dev/null
+++ b/src/tests/ag-grid-angular.spec.ts
@@ -0,0 +1,103 @@
+import { Component, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import { GridApi, GridOptions, GridReadyEvent, Module } from '@ag-grid-community/core';
+import { AgGridAngular } from '@ag-grid-community/angular';
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular],
+ template: ` `,
+})
+export class GridWrapperComponent {
+ modules: Module[] = [ClientSideRowModelModule];
+ rowData: any[] | null = null;
+ columnDefs = [{ field: 'make' }, { field: 'model' }, { field: 'price' }];
+
+ gridOptions: GridOptions = {};
+ gridApi!: GridApi;
+
+ suppressBrowserResizeObserver = false;
+
+ @ViewChild(AgGridAngular) agGrid!: AgGridAngular;
+
+ onGridReady(params: GridReadyEvent) {
+ this.gridApi = params.api;
+ // this.gridApi.setGridOption('rowData', [{ make: 'Toyota', model: 'Celica', price: 35000 }]);
+ this.rowData = [{ make: 'Toyota', model: 'Celica', price: 35000 }];
+ }
+ onFirstDataRendered(params: any) {}
+}
+
+describe('Grid OnReady', () => {
+ let component: GridWrapperComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [GridWrapperComponent, AgGridAngular],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GridWrapperComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('gridReady is completed by the time a timeout finishes', (done) => {
+ fixture.detectChanges();
+ setTimeout(() => {
+ expect(component.gridApi).toBeDefined();
+ done();
+ }, 0);
+ });
+
+ const runGridReadyTest = async () => {
+ spyOn(component, 'onGridReady').and.callThrough();
+ spyOn(component, 'onFirstDataRendered').and.callThrough();
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.gridApi).toBeDefined();
+
+ fixture.detectChanges(); // force rowData binding to be applied
+ await fixture.whenStable();
+
+ // If calling the gridApi.setRowData we don't need to call the fixture.detectChanges()
+
+ expect(component.onGridReady).toHaveBeenCalled();
+ expect(component.onFirstDataRendered).toHaveBeenCalled();
+ };
+
+ it('Fixture goes stable and calls gridReady', async () => {
+ await runGridReadyTest();
+ });
+
+ it('Fixture goes stable even with suppressBrowserResizeObserver= true', async () => {
+ // Test with the fallback polling to mimic Jest not supporting ResizeObserver
+ // We must have the polling run outside of the Angular zone
+ component.suppressBrowserResizeObserver = true;
+
+ await runGridReadyTest();
+ });
+
+ it('Grid Ready run Auto', async () => {
+ spyOn(component, 'onGridReady').and.callThrough();
+ spyOn(component, 'onFirstDataRendered').and.callThrough();
+ fixture.autoDetectChanges();
+ await fixture.whenStable();
+
+ expect(component.gridApi).toBeDefined();
+
+ expect(component.onGridReady).toHaveBeenCalled();
+ expect(component.onFirstDataRendered).toHaveBeenCalled();
+ });
+});
diff --git a/src/tests/clickingRows.spec.ts b/src/tests/clickingRows.spec.ts
new file mode 100644
index 0000000..5677200
--- /dev/null
+++ b/src/tests/clickingRows.spec.ts
@@ -0,0 +1,46 @@
+import { Component } from '@angular/core';
+
+import { render, screen } from '@testing-library/angular';
+import userEvent from '@testing-library/user-event';
+
+import { AgGridAngular } from '@ag-grid-community/angular';
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import { ColDef, GridOptions, ModuleRegistry } from '@ag-grid-community/core';
+
+ModuleRegistry.register(ClientSideRowModelModule);
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular],
+ template: `Row Clicked: {{ rowClicked?.make }}
+ `,
+})
+export class GridWrapperComponent {
+ rowData: any[] = [
+ { make: 'Toyota', model: 'Celica', price: 35000 },
+ { make: 'Ford', model: 'Mondeo', price: 32000 },
+ { make: 'Porsche', model: 'Boxster', price: 72000 },
+ ];
+ columnDefs: ColDef[] = [{ field: 'make' }, { field: 'model' }, { field: 'price' }];
+ rowClicked: any;
+
+ gridOptions: GridOptions = {
+ onRowClicked: (params) => {
+ this.rowClicked = params.data;
+ },
+ };
+}
+
+describe('Test Row Clicked', () => {
+ it('Test cell clicked run row handler', async () => {
+ render(GridWrapperComponent);
+
+ const row = await screen.findByText('Ford');
+
+ await userEvent.click(row);
+
+ const rowClicked = await screen.findByTestId('rowClicked');
+ expect(rowClicked.textContent).toBe('Row Clicked: Ford');
+ });
+});
diff --git a/src/tests/editor.spec.ts b/src/tests/editor.spec.ts
new file mode 100644
index 0000000..a5b3ad1
--- /dev/null
+++ b/src/tests/editor.spec.ts
@@ -0,0 +1,140 @@
+import { Component, ViewChild, ViewContainerRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import {
+ ColDef,
+ ICellEditorParams,
+ ICellRendererParams,
+ Module
+} from '@ag-grid-community/core';
+import { AgGridAngular, ICellEditorAngularComp, ICellRendererAngularComp } from '@ag-grid-community/angular';
+
+@Component({
+ standalone: true,
+ template: `£{{params?.value}}`,
+ })
+ export class PoundRenderer implements ICellRendererAngularComp {
+ params: ICellRendererParams | undefined;
+
+ agInit(params: ICellRendererParams): void {
+ this.params = params;
+ }
+
+ refresh(params: ICellRendererParams) {
+ this.params = params;
+ return true;
+ }
+ }
+
+@Component({
+ selector: 'editor-cell',
+ standalone: true,
+ imports: [FormsModule],
+ template: ` `,
+})
+export class EditorComponent implements ICellEditorAngularComp {
+ private params!: ICellEditorParams;
+ public value!: number;
+
+ @ViewChild('input', { read: ViewContainerRef }) public input!: ViewContainerRef;
+
+ agInit(params: ICellEditorParams): void {
+ this.params = params;
+ this.value = this.params.value;
+ }
+
+ getValue(): any {
+ return this.value;
+ }
+
+ // for testing
+ setValue(newValue: any) {
+ this.value = newValue;
+ }
+
+ isCancelBeforeStart(): boolean {
+ return false;
+ }
+
+ isCancelAfterEnd(): boolean {
+ return false;
+ }
+}
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular],
+ template: ` `,
+})
+export class TestHostComponent {
+ modules: Module[] = [ClientSideRowModelModule];
+
+ rowData: any[] = [{ name: 'Test Name', number: 42 }];
+ columnDefs: ColDef[] = [
+ { field: 'name' },
+ { field: 'number', colId: 'raw', headerName: 'Raw Number', editable: true, cellEditor: EditorComponent },
+ { field: 'number', colId: 'renderer', headerName: 'Renderer Value', cellRenderer: PoundRenderer },
+ ];
+
+ @ViewChild(AgGridAngular) public agGrid!: AgGridAngular;
+}
+
+describe('Editor Component', () => {
+ let component: TestHostComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('ViewChild not available until `detectChanges`', () => {
+ expect(component.agGrid).not.toBeTruthy();
+ });
+
+ it('ViewChild is available after `detectChanges`', async () => {
+ // Detect changes triggers the AgGridAngular lifecycle hooks
+ fixture.detectChanges();
+ // Wait for the fixture to stabilise
+ await fixture.whenStable();
+
+ expect(component.agGrid.api).toBeTruthy();
+ });
+
+ it('cell should be editable and editor component usable', async () => {
+ fixture.autoDetectChanges();
+ await fixture.whenStable();
+
+ // we use the API to start and stop editing - in a real e2e test we could actually double click on the cell etc
+ component.agGrid.api.startEditingCell({
+ rowIndex: 0,
+ colKey: 'raw',
+ });
+
+ const instances = component.agGrid.api.getCellEditorInstances();
+ expect(instances.length).toEqual(1);
+
+ const editorComponent = instances[0] as EditorComponent;
+ editorComponent.setValue(100);
+
+ component.agGrid.api.stopEditing();
+ await fixture.whenStable();
+
+ const cellElements = fixture.nativeElement.querySelectorAll('.ag-cell-value');
+ expect(cellElements.length).toEqual(3);
+
+ expect(cellElements[0].textContent).toEqual('Test Name');
+ expect(cellElements[1].textContent).toEqual('100');
+ expect(cellElements[2].textContent).toEqual('£100');
+ });
+});
diff --git a/src/tests/quick-filter.spec.ts b/src/tests/quick-filter.spec.ts
new file mode 100644
index 0000000..17ba1e4
--- /dev/null
+++ b/src/tests/quick-filter.spec.ts
@@ -0,0 +1,475 @@
+import { Component, DebugElement, OnInit, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import {
+ ColDef,
+ ModelUpdatedEvent,
+ Module
+} from '@ag-grid-community/core';
+import { By } from '@angular/platform-browser';
+import { AgGridAngular } from '@ag-grid-community/angular';
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular, FormsModule],
+ template: `
+
+ Number of rows: {{displayedRows}}
+ `,
+})
+export class TestHostComponent implements OnInit {
+ modules: Module[] = [ClientSideRowModelModule];
+
+ public quickFilterText: string = ''
+ public displayedRows: number = 0;
+
+ onModelUpdated(params: ModelUpdatedEvent) {
+ this.displayedRows = params.api.getDisplayedRowCount();
+ }
+
+ public columnDefs: ColDef[] = [
+ { field: 'name' },
+ { headerName: 'Age', field: 'person.age' },
+ { headerName: 'Country', field: 'person.country' },
+ ];
+ public rowData: any[] | null = null;
+
+ ngOnInit(): void {
+ this.rowData = getData();
+ }
+
+ @ViewChild(AgGridAngular) grid!: AgGridAngular;
+
+}
+
+describe('Quick Filter', () => {
+ let component: TestHostComponent;
+ let fixture: ComponentFixture;
+ // Get a reference to our quickFilter input
+ let quickFilterDE: DebugElement;
+ let rowNumberDE: DebugElement;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ });
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ component = fixture.componentInstance;
+ let compDebugElement = fixture.debugElement;
+
+ // Get a reference to our quickFilter input and rendered template
+ quickFilterDE = compDebugElement.query(By.css('#quickFilter'));
+ rowNumberDE = compDebugElement.query(By.css('#numberOfRows'));
+ });
+
+ it('should filter rows by quickFilterText using fakeAsync', fakeAsync(() => {
+ // When the test starts our test component has been created but not initialised yet.
+ // This means our component has not been created or had data passed to it yet.
+
+ // WARNING: When working with fakeAsync we must ensure our first call to `fixture.detectChanges()` is within our test body and NOT in a beforeEach section!
+ // This is vital as it means that during the construction of all async behaviour is correctly patched by fakeAsync
+ // If you had a fixture.detectChanges() in your beforeEach then you will find that flush has not impact on your grid async callbacks
+
+ expect(component.grid).toBeUndefined();
+ // Our first call to detectChanges, causes the grid to be create and passes the component values to the grid via its Inputs meaning the grid's internal model is setup
+ fixture.detectChanges();
+ // Grid has now been created
+ expect(component.grid.api).toBeDefined();
+ // We can test that the internal model of the grid is correct as it has 17 rows
+ // However, at this point Grid callbacks have not been run as they are async. i.e our (modelUpdated) Output has not fired
+ validateState({ gridRows: 17, displayedRows: 0, templateRows: 0 });
+
+ // To have all the async functions run we flush our fakeAsync test environment. This empties the call stack
+ flush();
+ // So now our component has its displayedRows property updated as the grid callback has been run
+ // However, this has not been reflected in our template yet as change detection has not run.
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 0 });
+
+ // We now run detectChanges which causes our template to update using the latest values in our component
+ fixture.detectChanges();
+ // We have now reached our first stable state with consistency between the internal grid model, component data and renderer template output
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 });
+
+ // Now let's test that updating the filter text input does filter the grid data.
+ // Set the filter to United States
+ quickFilterDE.nativeElement.value = 'United States';
+ quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
+
+ // At this point our text input has been updated but the two way binding [(ngModel)]="quickFilterText" has not been applied
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 });
+
+ // We force change detection to run which applies the update to our {
+
+ fixture.detectChanges()
+ flush();
+ fixture.detectChanges()
+
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 })
+
+ quickFilterDE.nativeElement.value = 'United States'
+ quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
+
+ fixture.detectChanges()
+ flush()
+ fixture.detectChanges()
+
+ validateState({ gridRows: 10, displayedRows: 10, templateRows: 10 })
+ }))
+
+ it('should filter rows by quickFilterText (async await)', (async () => {
+
+ // When the test starts the component has been created but is not initialised.
+ // This means the component has not been created or had data passed to it.
+ // To validate this, test that the grid is undefined at the start of the test.
+ expect(component.grid).toBeUndefined()
+
+ // When working with fakeAsync ensure the first call to `fixture.detectChanges()`
+ // is within the test body and NOT in a beforeEach section.
+ // This is vital as it means that during the construction of
+ // all async behaviour is correctly patched.
+
+ // The first call to detectChanges, creates the grid and binds the component values to the grid via its @Inputs.
+ fixture.detectChanges()
+ // Next validate that the grid has now been created.
+ expect(component.grid.api).toBeDefined()
+
+ // Now validate that the internal grid model is correct. It should have 17 rows.
+ // However, at this point the asynchronous grid callbacks have not run.
+ // i.e the (modelUpdated) @Output has not fired.
+ // This is why the internal grid state has 17 rows, but the component and template still have 0 values.
+ validateState({ gridRows: 17, displayedRows: 0, templateRows: 0 })
+
+ // Wait for the fixture to be stable which allows all the asynchronous code to run.
+ await fixture.whenStable()
+
+ // Now that the fixture is stable validate that the async callback (modelUpdated) has run.
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 0 })
+
+ // Run change detection to update the template based off the new component state
+ fixture.detectChanges()
+
+ // The grid is now stable and the template value matches
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 })
+
+ // Now update the filter text input.
+ // Set the filter value to 'United States' and fire the input event
+ // which is required for ngModel to see the change.
+ quickFilterDE.nativeElement.value = 'United States'
+ quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
+
+ // Force change detection to run to apply the update to the {
+
+ fixture.detectChanges()
+ await fixture.whenStable()
+ fixture.detectChanges()
+
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 })
+
+ quickFilterDE.nativeElement.value = 'United States'
+ quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
+
+ fixture.detectChanges()
+ await fixture.whenStable()
+ fixture.detectChanges()
+
+ validateState({ gridRows: 10, displayedRows: 10, templateRows: 10 })
+ }))
+
+ it('should filter rows by quickFilterText (async await) auto detect', (async () => {
+
+ fixture.autoDetectChanges()
+ await fixture.whenStable()
+
+ validateState({ gridRows: 17, displayedRows: 17, templateRows: 17 })
+
+ quickFilterDE.nativeElement.value = 'United States'
+ quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
+
+ await fixture.whenStable()
+
+ validateState({ gridRows: 10, displayedRows: 10, templateRows: 10 })
+ }))
+
+ // Helper function to validate our internal grid model, component state and the rendered output in our template
+ function validateState({
+ gridRows,
+ displayedRows,
+ templateRows,
+ }: {
+ gridRows: number;
+ displayedRows: number;
+ templateRows: number;
+ }) {
+ expect(component.grid.api).toBeDefined();
+ expect(component.grid.api.getDisplayedRowCount())
+ .withContext('api.getDisplayedRowCount')
+ .toEqual(gridRows);
+ expect(component.displayedRows)
+ .withContext('component.displayedRows')
+ .toEqual(displayedRows);
+ expect(rowNumberDE.nativeElement.innerHTML.trim())
+ .withContext(' {{displayedRows}}
')
+ .toContain(templateRows);
+ }
+ });
+
+
+export function getData(): any[] {
+ const rowData = [
+ {
+ name: 'Michael Phelps',
+ person: {
+ age: 23,
+ country: 'United States',
+ },
+ medals: {
+ gold: 8,
+ silver: 0,
+ bronze: 0,
+ },
+ },
+ {
+ name: 'Michael Phelps',
+ person: {
+ age: 19,
+ country: 'United States',
+ },
+ medals: {
+ gold: 6,
+ silver: 0,
+ bronze: 2,
+ },
+ },
+ {
+ name: 'Michael Phelps',
+ person: {
+ age: 27,
+ country: 'United States',
+ },
+ medals: {
+ gold: 4,
+ silver: 2,
+ bronze: 0,
+ },
+ },
+ {
+ name: 'Natalie Coughlin',
+ person: {
+ age: 25,
+ country: 'United States',
+ },
+ medals: {
+ gold: 1,
+ silver: 2,
+ bronze: 3,
+ },
+ },
+ {
+ name: 'Aleksey Nemov',
+ person: {
+ age: 24,
+ country: 'Russia',
+ },
+ medals: {
+ gold: 2,
+ silver: 1,
+ bronze: 3,
+ },
+ },
+ {
+ name: 'Alicia Coutts',
+ person: {
+ age: 24,
+ country: 'Australia',
+ },
+ medals: {
+ gold: 1,
+ silver: 3,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Missy Franklin',
+ person: {
+ age: 17,
+ country: 'United States',
+ },
+ medals: {
+ gold: 4,
+ silver: 0,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Ryan Lochte',
+ person: {
+ age: 27,
+ country: 'United States',
+ },
+ medals: {
+ gold: 2,
+ silver: 2,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Allison Schmitt',
+ person: {
+ age: 22,
+ country: 'United States',
+ },
+ medals: {
+ gold: 3,
+ silver: 1,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Natalie Coughlin',
+ person: {
+ age: 21,
+ country: 'United States',
+ },
+ medals: {
+ gold: 2,
+ silver: 2,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Ian Thorpe',
+ person: {
+ age: 17,
+ country: 'Australia',
+ },
+ medals: {
+ gold: 3,
+ silver: 2,
+ bronze: 0,
+ },
+ },
+ {
+ name: 'Dara Torres',
+ person: {
+ age: 33,
+ country: 'United States',
+ },
+ medals: {
+ gold: 2,
+ silver: 0,
+ bronze: 3,
+ },
+ },
+ {
+ name: 'Cindy Klassen',
+ person: {
+ age: 26,
+ country: 'Canada',
+ },
+ medals: {
+ gold: 1,
+ silver: 2,
+ bronze: 2,
+ },
+ },
+ {
+ name: 'Nastia Liukin',
+ person: {
+ age: 18,
+ country: 'United States',
+ },
+ medals: {
+ gold: 1,
+ silver: 3,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Marit Bjørgen',
+ person: {
+ age: 29,
+ country: 'Norway',
+ },
+ medals: {
+ gold: 3,
+ silver: 1,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Sun Yang',
+ person: {
+ age: 20,
+ country: 'China',
+ },
+ medals: {
+ gold: 2,
+ silver: 1,
+ bronze: 1,
+ },
+ },
+ {
+ name: 'Kirsty Coventry',
+ person: {
+ age: 24,
+ country: 'Zimbabwe',
+ },
+ medals: {
+ gold: 1,
+ silver: 3,
+ bronze: 0,
+ },
+ },
+ ];
+ return rowData;
+};
\ No newline at end of file
diff --git a/src/tests/zones.colDef.spec.ts b/src/tests/zones.colDef.spec.ts
new file mode 100644
index 0000000..88fdb32
--- /dev/null
+++ b/src/tests/zones.colDef.spec.ts
@@ -0,0 +1,109 @@
+import { Component, NgZone } from '@angular/core';
+
+import { fireEvent, render, screen, within } from '@testing-library/angular';
+import userEvent from '@testing-library/user-event';
+
+import { AgGridAngular } from '@ag-grid-community/angular';
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import { CellClickedEvent, CellDoubleClickedEvent, ColDef, GridOptions, Module, NewValueParams } from '@ag-grid-community/core';
+import { MenuModule } from '@ag-grid-enterprise/menu';
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular],
+ template: ` `,
+})
+export class GridWrapperComponent {
+ modules: Module[] = [ClientSideRowModelModule, MenuModule];
+ rowData: any[] = [{ make: 'Toyota', model: 'Celica', price: 35000 }];
+ columnDefs: ColDef[] = [
+ {
+ field: 'make',
+ editable: true,
+ onCellClicked: (event: CellClickedEvent) => {
+ this.zoneStatus['cellClicked'] = NgZone.isInAngularZone();
+ },
+ onCellDoubleClicked: (event: CellDoubleClickedEvent) => {
+ this.zoneStatus['cellDoubleClicked'] = NgZone.isInAngularZone();
+ },
+ onCellValueChanged: (event: NewValueParams) => {
+ this.zoneStatus['cellValueChanged'] = NgZone.isInAngularZone();
+ },
+ onCellContextMenu: (event: CellClickedEvent) => {
+ this.zoneStatus['cellContextMenu'] = NgZone.isInAngularZone();
+ },
+
+ },
+ { field: 'model' },
+ { field: 'price' },
+ ];
+
+ gridOptions: GridOptions = {
+ getContextMenuItems: (params) => {
+ return [
+ {
+ name: 'Custom Menu Item',
+ action: () => {
+ this.zoneStatus['customMenuItem'] = NgZone.isInAngularZone();
+ },
+ },
+ ];
+ }
+ };
+
+ public zoneStatus: { [key: string]: boolean } = {};
+
+}
+
+describe('Test ColDef Event ZoneJs Status', () => {
+
+ // suppress console errors
+ let originalError: any;
+ beforeAll(() =>{
+ originalError = console.error;
+ spyOn(console, 'error');
+ })
+ afterAll(() =>{
+ console.error = originalError;
+ });
+
+
+ it('Test cell is rendered and price updated', async () => {
+ await render(GridWrapperComponent);
+ const toyota = await screen.findByText('Toyota');
+ expect(toyota).toBeDefined();
+ });
+
+ it('Test cell clicked is run in Zone', async () => {
+ const component = await render(GridWrapperComponent);
+
+ const toyota = await screen.findByText('Toyota');
+
+ const user = userEvent.setup();
+ // Validate clicks
+ await user.click(toyota);
+ await user.dblClick(toyota);
+
+ // Validate cell value changed
+ let input: HTMLInputElement = within(toyota).getByLabelText('Input Editor');
+ await userEvent.keyboard('New Toyota');
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
+ await screen.findByText('New Toyota');
+
+ // Validate context menu
+ await user.pointer({keys: '[MouseRight]', target: toyota});
+ const customMenuItem = await screen.findByText('Custom Menu Item');
+
+ await user.click(customMenuItem);
+
+ expect(component.fixture.componentInstance.zoneStatus['cellClicked']).toBeTrue();
+ expect(component.fixture.componentInstance.zoneStatus['cellDoubleClicked']).toBeTrue();
+ expect(component.fixture.componentInstance.zoneStatus['cellValueChanged']).toBeTrue();
+ expect(component.fixture.componentInstance.zoneStatus['cellContextMenu']).toBeTrue();
+ });
+});
diff --git a/src/tests/zones.spec.ts b/src/tests/zones.spec.ts
new file mode 100644
index 0000000..e191156
--- /dev/null
+++ b/src/tests/zones.spec.ts
@@ -0,0 +1,308 @@
+import { Component, NgZone, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
+import {
+ GetRowIdParams,
+ GridApi,
+ GridOptions,
+ GridReadyEvent,
+ ICellRendererParams,
+ Module,
+ RowClassParams
+} from '@ag-grid-community/core';
+import { AgGridAngular, ICellRendererAngularComp } from '@ag-grid-community/angular';
+
+// These tests are run to validate that the grid is running in / out of the Angular Zone as expected
+// We need to ensure that custom components are running in the Angular Zone so that change detection
+// is triggered correctly.
+// We also test that callbacks / events are running correctly out / in of the Angular Zone.
+
+function updateCount(counts: Record, key: string, isInAngularZone: boolean) {
+ counts[key] = isInAngularZone;
+}
+
+@Component({
+ selector: 'test-comp',
+ standalone: true,
+ imports: [],
+ template: ` {{ getName() }} `,
+})
+export class TestComponent implements ICellRendererAngularComp {
+ // Validate that component variables are running in Zone
+ private testInZone = NgZone.isInAngularZone();
+ private params!: ICellRendererParams & { getZoneStatus: () => any };
+
+ agInit(params: ICellRendererParams & { getZoneStatus: () => any }): void {
+ // Validate that agInit is running inside Zone
+ updateCount(params.getZoneStatus(), 'TestComp -> agInit', NgZone.isInAngularZone());
+ updateCount(params.getZoneStatus(), 'TestComp -> class variable', this.testInZone);
+ this.params = params;
+ }
+
+ refresh(params: ICellRendererParams & { getZoneStatus: () => any }) {
+ updateCount(params.getZoneStatus(), 'TestComp -> refresh', NgZone.isInAngularZone());
+ return false;
+ }
+
+ getName() {
+ // This validates that the template is running in the correct zone which is important for
+ // any other components that are rendered in the template
+ updateCount(this.params.getZoneStatus(), 'TestComp -> template1', NgZone.isInAngularZone());
+ return 'Test';
+ }
+}
+
+@Component({
+ selector: 'app-grid-wrapper',
+ standalone: true,
+ imports: [AgGridAngular],
+ template:
+ ' ',
+})
+export class GridWrapperComponent {
+ modules: Module[] = [ClientSideRowModelModule];
+ rowData: any[] = [{ make: 'Toyota', model: 'Celica', price: 35000 }];
+ columnDefs = [{ field: 'make' }, { field: 'model' }, { field: 'price', cellRenderer: TestComponent, cellRendererParams: { getZoneStatus: () => this.zoneStatus} }];
+
+ gridOptions: GridOptions = {
+ getRowClass: (params: RowClassParams) => {
+ // Callbacks should run outside of Angular Zone as they are just for configuring the grid
+ // and they get called a lot in some cases.
+ updateCount(this.zoneStatus, 'gridOptions -> callback', NgZone.isInAngularZone());
+ return 'my-class';
+ },
+ onStateUpdated: () => {
+ // Events should run inside Angular Zone as they are triggered by the grid for updating
+ // user applications.
+ updateCount(this.zoneStatus, 'gridOptions -> event', NgZone.isInAngularZone());
+ },
+ };
+
+ zoneStatus: any = {};
+
+ // Method will be provided by test case
+ setupListeners: (zone: NgZone, api: GridApi, zoneStatus: any) => void = () => {};
+
+ @ViewChild(AgGridAngular) agGrid!: AgGridAngular;
+
+ constructor(private zone: NgZone) {}
+
+ onGridReady(params: GridReadyEvent) {
+ // Validate event passed to component is running in Angular Zone
+ updateCount(this.zoneStatus, 'component -> event', NgZone.isInAngularZone());
+
+ this.setupListeners(this.zone, params.api, this.zoneStatus);
+ }
+
+ getRowId = (params: GetRowIdParams) => {
+ // Validate callback passed to component is run outside of Angular Zone
+ updateCount(this.zoneStatus, 'component -> callback', NgZone.isInAngularZone());
+ return params.data.make;
+ };
+}
+
+describe('GridWrapperComponent', () => {
+ let component: GridWrapperComponent;
+ let fixture: ComponentFixture;
+
+ beforeAll(() => {
+ (window as any).AG_GRID_UNDER_TEST = false;
+ });
+ afterAll(() => {
+ (window as any).AG_GRID_UNDER_TEST = undefined;
+ });
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [GridWrapperComponent, AgGridAngular],
+ }).compileComponents();
+ });
+
+ beforeEach(async () => {
+ fixture = TestBed.createComponent(GridWrapperComponent);
+ component = fixture.componentInstance;
+ component.zoneStatus = {};
+ fixture.detectChanges();
+ });
+
+ it('should run in / out Angular Zone', (done) => {
+ // Access AG Grid API and check if the row data is updated
+ const api = fixture.componentInstance.agGrid.api;
+ expect(api).toBeDefined();
+ expect(api.getDisplayedRowCount()).toEqual(1);
+
+ fixture.componentInstance.setupListeners = (zone: NgZone, api: GridApi, zoneStatus: any) => {
+ setupTestListeners(zoneStatus, api);
+ };
+
+ fixture.detectChanges();
+
+ // We have to use setTimouts as we have disabled the AG_GRID_UNDER_TEST flag
+ setTimeout(() => {
+ api.updateGridOptions({ columnDefs: [{ field: 'make' }, { field: 'model' }] });
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ assertZoneStatuses(component.zoneStatus, {
+ 'Column -> eventListener': true,
+ 'RowNode -> eventListener': true,
+ 'api -> eventListener': true,
+ 'api -> globalListener': true,
+ 'component -> callback': false,
+ 'component -> event': true,
+ 'gridOptions -> callback': false,
+ 'gridOptions -> event': true,
+ 'TestComp -> agInit': true,
+ 'TestComp -> class variable': true,
+ 'TestComp -> template1': true,
+ 'TestComp -> refresh': true,
+ });
+
+ done();
+ }, 1500);
+ }, 1500);
+ });
+
+ it('should remove event listeners', (done) => {
+ // Access AG Grid API and check if the row data is updated
+ const api = fixture.componentInstance.agGrid.api;
+ expect(api).toBeDefined();
+
+ const listeners = {
+ eventListener: (event: any) => {},
+ globalEventListener: (event: any) => {},
+ rowEventListener: (event: any) => {},
+ columnEventListener: (event: any) => {},
+ };
+
+ const eventListenerSpy = spyOn(listeners, 'eventListener');
+ const globalEventListenerSpy = spyOn(listeners, 'globalEventListener');
+ const rowEventListenerSpy = spyOn(listeners, 'rowEventListener');
+ const columnEventListenerSpy = spyOn(listeners, 'columnEventListener');
+
+ // Test add and remove works
+ api.addEventListener('newColumnsLoaded', listeners.eventListener);
+ api.removeEventListener('newColumnsLoaded', listeners.eventListener);
+
+ api.addGlobalListener(listeners.globalEventListener);
+ api.removeGlobalListener(listeners.globalEventListener);
+
+ api.getRowNode('Toyota')?.addEventListener('dataChanged', listeners.rowEventListener);
+ api.getRowNode('Toyota')?.removeEventListener('dataChanged', listeners.rowEventListener);
+
+ api.getColumn('make')?.addEventListener('visibleChanged', listeners.columnEventListener);
+ api.getColumn('make')?.removeEventListener('visibleChanged', listeners.columnEventListener);
+
+ api.getRowNode('Toyota')?.setData({ make: 'Toyota', model: 'Celica', price: 40000 });
+
+ api.applyColumnState({
+ state: [
+ {
+ colId: 'make',
+ hide: true,
+ },
+ ],
+ });
+
+ setTimeout(() => {
+ api.updateGridOptions({ columnDefs: [{ field: 'make' }, { field: 'model' }] });
+ fixture.detectChanges();
+
+ expect(eventListenerSpy).toHaveBeenCalledTimes(0);
+ expect(globalEventListenerSpy).toHaveBeenCalledTimes(0);
+ expect(rowEventListenerSpy).toHaveBeenCalledTimes(0);
+ expect(columnEventListenerSpy).toHaveBeenCalledTimes(0);
+
+ done();
+ }, 100);
+ });
+
+ it('should run in / out Angular Zone Setup Outside', (done) => {
+ // Access AG Grid API and check if the row data is updated
+ const api = fixture.componentInstance.agGrid.api;
+ expect(api).toBeDefined();
+ expect(api.getDisplayedRowCount()).toEqual(1);
+
+ fixture.componentInstance.setupListeners = (zone: NgZone, api: GridApi, zoneStatus: any) => {
+ // For testing purposes, we are going to add listeners to the grid outside of angular
+ // This is to enable users to add listeners outside of angular and still have them run outside
+ zone.runOutsideAngular(() => {
+ setupTestListeners(zoneStatus, api);
+ });
+ };
+
+ setTimeout(() => {
+ api.updateGridOptions({ columnDefs: [{ field: 'make' }, { field: 'model' }] });
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ assertZoneStatuses(component.zoneStatus, {
+ 'Column -> eventListener': false, // Will stay outside
+ 'RowNode -> eventListener': false, // Will stay outside
+ 'api -> eventListener': false, // Will stay outside
+ 'api -> globalListener': false, // Will stay outside
+ 'component -> callback': false,
+ 'component -> event': true,
+ 'gridOptions -> callback': false,
+ 'gridOptions -> event': true,
+ 'TestComp -> agInit': true,
+ 'TestComp -> class variable': true,
+ 'TestComp -> template1': true,
+ 'TestComp -> refresh': true,
+ });
+
+ done();
+ }, 1500);
+ }, 1500);
+ });
+});
+
+function assertZoneStatuses(zoneStatus: Record, expected: Record) {
+ const results = Object.fromEntries(
+ Object.entries(zoneStatus).map(([key, value]) => {
+ const valueString = JSON.stringify(value);
+ return [key, valueString];
+ })
+ );
+
+ Object.keys(expected).forEach((key) => {
+ expect(`${key}: ${results[key]}`).toBe(`${key}: ${expected[key]}`);
+ });
+ expect(Object.keys(results).sort().join()).toBe(Object.keys(expected).sort().join());
+}
+
+function setupTestListeners(zoneStatus: any, api: GridApi) {
+ const globalListener = (event: any) => {
+ updateCount(zoneStatus, 'api -> globalListener', NgZone.isInAngularZone());
+ };
+
+ const eventListener = (event: any) => {
+ updateCount(zoneStatus, 'api -> eventListener', NgZone.isInAngularZone());
+ };
+
+ // Test that api added event listeners are running in the Angular Zone
+ // Both global and individual events
+ api.addGlobalListener(globalListener);
+ api.addEventListener('newColumnsLoaded', eventListener);
+
+ api.getRowNode('Toyota')?.addEventListener('dataChanged', (event: any) => {
+ updateCount(zoneStatus, 'RowNode -> eventListener', NgZone.isInAngularZone());
+ });
+
+ api.getColumn('make')?.addEventListener('visibleChanged', (event: any) => {
+ updateCount(zoneStatus, 'Column -> eventListener', NgZone.isInAngularZone());
+ });
+
+ api.getRowNode('Toyota')?.setData({ make: 'Toyota', model: 'Celica', price: 40000 });
+ api.getRowNode('Toyota')?.setDataValue('price', 40);
+
+ api.applyColumnState({
+ state: [
+ {
+ colId: 'make',
+ hide: true,
+ },
+ ],
+ });
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7303716..1a35a42 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,7 +17,7 @@
"target": "es2020",
"module": "es2020",
"lib": [
- "es2018",
+ "es2020",
"dom"
],
"paths": {