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: - - + + 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": {