From b734069a0f8d66ab72feb3b5d6081baf2b4121ea Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:11:43 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Generate Unit Tests for PR Changes --- .../tests/row-focus-state.ng.spec.ts | 562 ++++++++++++++ .../tests/row-group.ng.spec.ts | 710 +++++++++++++++++- .../tests/row-selection-state.ng.spec.ts | 429 +++++++++++ 3 files changed, 1700 insertions(+), 1 deletion(-) create mode 100644 packages/ag-grid-angular-theme/tests/row-focus-state.ng.spec.ts create mode 100644 packages/ag-grid-angular-theme/tests/row-selection-state.ng.spec.ts diff --git a/packages/ag-grid-angular-theme/tests/row-focus-state.ng.spec.ts b/packages/ag-grid-angular-theme/tests/row-focus-state.ng.spec.ts new file mode 100644 index 0000000..6a69ed0 --- /dev/null +++ b/packages/ag-grid-angular-theme/tests/row-focus-state.ng.spec.ts @@ -0,0 +1,562 @@ +import { Component, Directive, forwardRef, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { render, waitFor } from '@testing-library/angular'; +import { AgGridAngular } from 'ag-grid-angular'; +import { AgEventType, GridApi, IRowNode } from 'ag-grid-community'; +import { Subject } from 'rxjs'; +import { + KBQ_AG_GRID_ROW_FOCUS_STATE_STORE, + KbqAgGridRowFocusState, + KbqAgGridRowFocusStateLocalStorageStore, + KbqAgGridRowFocusStateQueryParamsStore, + KbqAgGridRowFocusStateStore, + KbqAgGridRowFocusStateValue, + kbqAgGridRowFocusStateStoreProvider +} from '../src/row-focus-state.ng'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyEventHandler = (event?: any) => void; + +const makeNode = (id: string, rowIndex: number | null): IRowNode => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { id, rowIndex } as unknown as IRowNode; +}; + +const createApiMock = ( + nodesById: Record = {}, + nodesByIndex: Record = {} +): { + api: GridApi; + dispatch: (eventName: AgEventType, event?: object) => void; +} => { + const listeners = new Map(); + + const api = { + addEventListener: jest.fn((eventName: AgEventType, handler: AnyEventHandler) => { + const eventListeners = listeners.get(eventName) ?? []; + eventListeners.push(handler); + listeners.set(eventName, eventListeners); + }), + removeEventListener: jest.fn((eventName: AgEventType, handler: AnyEventHandler) => { + const eventListeners = listeners.get(eventName) ?? []; + listeners.set( + eventName, + eventListeners.filter((listener) => listener !== handler) + ); + }), + getRowNode: jest.fn((id: string) => nodesById[id]), + getDisplayedRowAtIndex: jest.fn((index: number) => nodesByIndex[index]), + setFocusedCell: jest.fn(), + clearFocusedCell: jest.fn() + }; + + const dispatch = (eventName: AgEventType, event?: object): void => { + const eventListeners = listeners.get(eventName) ?? []; + eventListeners.forEach((listener) => listener(event)); + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { api: api as unknown as GridApi, dispatch }; +}; + +@Directive({ + selector: 'ag-grid-angular', + standalone: true, + providers: [{ provide: AgGridAngular, useExisting: forwardRef(() => TestAgGridAngularStub) }] +}) +class TestAgGridAngularStub { + readonly gridReady = new Subject<{ api: GridApi }>(); + readonly api = createApiMock().api; + + emitGridReady(api: GridApi = this.api): void { + this.gridReady.next({ api }); + } +} + +@Component({ + selector: 'test-row-focus-state-grid', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowFocusState] +}) +class TestRowFocusStateGrid { + key = 'row-focus'; + store: KbqAgGridRowFocusStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowFocusState); +} + +describe(KbqAgGridRowFocusState.name, () => { + it('restores saved active cell from store on firstDataRendered', async () => { + const savedValue: KbqAgGridRowFocusStateValue = { rowId: 'b', colId: 'athlete' }; + const nodeB = makeNode('b', 1); + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => savedValue), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock({ b: nodeB }); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-1', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-focus-1'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setFocusedCell).toHaveBeenCalledWith(1, 'athlete'); + }); + }); + + it('does not focus anything when store returns null', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-2', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-focus-2'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setFocusedCell).not.toHaveBeenCalled(); + }); + }); + + it('does not restore when the saved row id no longer resolves to a row index', async () => { + const savedValue: KbqAgGridRowFocusStateValue = { rowId: 'missing', colId: 'athlete' }; + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => savedValue), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-3', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-focus-3'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setFocusedCell).not.toHaveBeenCalled(); + }); + }); + + it('saves the active cell on cellFocused', async () => { + const nodeA = makeNode('a', 0); + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock({}, { 0: nodeA }); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-4', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { + rowIndex: 0, + rowPinned: null, + column: { getColId: () => 'athlete' } + }); + + await waitFor(() => { + expect(store.setItem).toHaveBeenCalledWith('grid-row-focus-4', { rowId: 'a', colId: 'athlete' }); + }); + }); + + it('removes stored state when focus moves to a pinned row', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-5', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { + rowIndex: 0, + rowPinned: 'top', + column: { getColId: () => 'athlete' } + }); + + await waitFor(() => { + expect(store.removeItem).toHaveBeenCalledWith('grid-row-focus-5'); + expect(store.setItem).not.toHaveBeenCalled(); + }); + }); + + it('does not resave the cell focused as a result of its own restore call', async () => { + const savedValue: KbqAgGridRowFocusStateValue = { rowId: 'b', colId: 'athlete' }; + const nodeB = makeNode('b', 1); + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => savedValue), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock({ b: nodeB }, { 1: nodeB }); + + // setFocusedCell dispatches `cellFocused` synchronously, mirroring real ag-grid behavior. + jest.spyOn(apiMock.api, 'setFocusedCell').mockImplementation((rowIndex, colKey) => { + apiMock.dispatch('cellFocused', { rowIndex, rowPinned: null, column: { getColId: () => colKey } }); + }); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-6', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setFocusedCell).toHaveBeenCalledWith(1, 'athlete'); + }); + + expect(store.setItem).not.toHaveBeenCalled(); + }); + + it('reset removes stored state and clears grid focus', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-7', store } + }); + + fixture.componentInstance.directive().reset(); + + expect(store.removeItem).toHaveBeenCalledWith('grid-row-focus-7'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(fixture.componentInstance.grid().api.clearFocusedCell).toHaveBeenCalled(); + }); + + it('removes all event listeners on destroy', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-8', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-type-assertion + const addEventListenerMock = apiMock.api.addEventListener as unknown as jest.MockedFunction< + typeof apiMock.api.addEventListener + >; + + const getHandler = (eventName: AgEventType): AnyEventHandler | undefined => + addEventListenerMock.mock.calls.find(([name]) => name === eventName)?.[1]; + + fixture.destroy(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.removeEventListener).toHaveBeenCalledWith( + 'firstDataRendered', + getHandler('firstDataRendered') + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.removeEventListener).toHaveBeenCalledWith('cellFocused', getHandler('cellFocused')); + }); + + describe('cellFocused edge cases', () => { + it('removes stored state when rowIndex is null', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-9', store } + }); + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { + rowIndex: null, + rowPinned: null, + column: { getColId: () => 'athlete' } + }); + + await waitFor(() => { + expect(store.removeItem).toHaveBeenCalledWith('grid-row-focus-9'); + expect(store.setItem).not.toHaveBeenCalled(); + }); + }); + + it('removes stored state when column is null', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock({}, { 0: makeNode('a', 0) }); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-10', store } + }); + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { rowIndex: 0, rowPinned: null, column: null }); + + await waitFor(() => { + expect(store.removeItem).toHaveBeenCalledWith('grid-row-focus-10'); + expect(store.setItem).not.toHaveBeenCalled(); + }); + }); + + it('removes stored state when the focused row cannot be resolved to an id', async () => { + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + // No node registered at index 0 — getDisplayedRowAtIndex resolves to undefined. + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-11', store } + }); + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { + rowIndex: 0, + rowPinned: null, + column: { getColId: () => 'athlete' } + }); + + await waitFor(() => { + expect(store.removeItem).toHaveBeenCalledWith('grid-row-focus-11'); + expect(store.setItem).not.toHaveBeenCalled(); + }); + }); + + it('accepts a string column key directly, without calling getColId', async () => { + const nodeA = makeNode('a', 0); + const store: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock({}, { 0: nodeA }); + + const { fixture } = await render(TestRowFocusStateGrid, { + componentProperties: { key: 'grid-row-focus-12', store } + }); + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('cellFocused', expect.any(Function)); + }); + + apiMock.dispatch('cellFocused', { rowIndex: 0, rowPinned: null, column: 'athlete' }); + + await waitFor(() => { + expect(store.setItem).toHaveBeenCalledWith('grid-row-focus-12', { rowId: 'a', colId: 'athlete' }); + }); + }); + }); +}); + +describe(KbqAgGridRowFocusStateLocalStorageStore.name, () => { + const key = 'row-focus-local-storage-test-key'; + + afterEach(() => { + window.localStorage.removeItem(key); + }); + + it('getItem returns null when nothing is stored', () => { + const store = new KbqAgGridRowFocusStateLocalStorageStore(); + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem stores the value as JSON, and getItem parses it back', () => { + const store = new KbqAgGridRowFocusStateLocalStorageStore(); + const value: KbqAgGridRowFocusStateValue = { rowId: 'row-1', colId: 'athlete' }; + + store.setItem(key, value); + + expect(window.localStorage.getItem(key)).toBe(JSON.stringify(value)); + expect(store.getItem(key)).toEqual(value); + }); + + it('removeItem deletes the stored value', () => { + const store = new KbqAgGridRowFocusStateLocalStorageStore(); + store.setItem(key, { rowId: 'row-1', colId: 'athlete' }); + + store.removeItem(key); + + expect(window.localStorage.getItem(key)).toBeNull(); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem returns null when the stored value is not valid JSON', () => { + const store = new KbqAgGridRowFocusStateLocalStorageStore(); + window.localStorage.setItem(key, 'not-json{{{'); + + expect(store.getItem(key)).toBeNull(); + }); +}); + +describe(KbqAgGridRowFocusStateQueryParamsStore.name, () => { + const key = 'row-focus-query-params-test-key'; + let navigate: jest.Mock; + let store: KbqAgGridRowFocusStateQueryParamsStore; + + beforeEach(() => { + navigate = jest.fn(() => Promise.resolve(true)); + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: { navigate } }] + }); + store = TestBed.inject(KbqAgGridRowFocusStateQueryParamsStore); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('getItem returns null when the query param is absent', () => { + window.history.pushState({}, '', '/?other=1'); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem parses the value from the URL query string', () => { + const value: KbqAgGridRowFocusStateValue = { rowId: 'row-1', colId: 'athlete' }; + window.history.pushState({}, '', `/?${key}=${encodeURIComponent(JSON.stringify(value))}`); + + expect(store.getItem(key)).toEqual(value); + }); + + it('getItem returns null when the query param is not valid JSON', () => { + window.history.pushState({}, '', `/?${key}=not-json{{{`); + + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem navigates with the JSON-stringified value merged into query params', async () => { + const value: KbqAgGridRowFocusStateValue = { rowId: 'row-1', colId: 'athlete' }; + + await store.setItem(key, value); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: JSON.stringify(value) }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); + + it('removeItem navigates with the query param set to null', async () => { + await store.removeItem(key); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); +}); + +describe(kbqAgGridRowFocusStateStoreProvider.name, () => { + it('binds the token to useClass when given a class', () => { + const provider = kbqAgGridRowFocusStateStoreProvider(KbqAgGridRowFocusStateQueryParamsStore); + + expect(provider).toEqual({ + provide: KBQ_AG_GRID_ROW_FOCUS_STATE_STORE, + useClass: KbqAgGridRowFocusStateQueryParamsStore + }); + }); + + it('binds the token to useValue when given an instance', () => { + const instance: KbqAgGridRowFocusStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + const provider = kbqAgGridRowFocusStateStoreProvider(instance); + + expect(provider).toEqual({ provide: KBQ_AG_GRID_ROW_FOCUS_STATE_STORE, useValue: instance }); + }); + + it('resolves to the provided store instance through Angular DI', () => { + const instance: KbqAgGridRowFocusStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + TestBed.configureTestingModule({ providers: [kbqAgGridRowFocusStateStoreProvider(instance)] }); + + expect(TestBed.inject(KBQ_AG_GRID_ROW_FOCUS_STATE_STORE)).toBe(instance); + }); +}); diff --git a/packages/ag-grid-angular-theme/tests/row-group.ng.spec.ts b/packages/ag-grid-angular-theme/tests/row-group.ng.spec.ts index d855f8f..57c2fa0 100644 --- a/packages/ag-grid-angular-theme/tests/row-group.ng.spec.ts +++ b/packages/ag-grid-angular-theme/tests/row-group.ng.spec.ts @@ -1,13 +1,25 @@ import { Component, Directive, forwardRef, input, signal, Type, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { render, waitFor } from '@testing-library/angular'; import { AgGridAngular } from 'ag-grid-angular'; import { ColDef, ColGroupDef, GridApi, IRowNode } from 'ag-grid-community'; import { Subject } from 'rxjs'; import { + KBQ_AG_GRID_ROW_GROUP_COLLAPSED_STATE_STORE, + KBQ_AG_GRID_ROW_GROUP_SELECTION_STATE_STORE, KbqAgGridRowGroup, KbqAgGridRowGroupCellContent, + KbqAgGridRowGroupCollapsedStateLocalStorageStore, + KbqAgGridRowGroupCollapsedStateQueryParamsStore, + KbqAgGridRowGroupCollapsedStateStore, KbqAgGridRowGroupInfo, - KbqAgGridRowGroupRowId + KbqAgGridRowGroupRowId, + KbqAgGridRowGroupSelectionStateLocalStorageStore, + KbqAgGridRowGroupSelectionStateQueryParamsStore, + KbqAgGridRowGroupSelectionStateStore, + kbqAgGridRowGroupCollapsedStateStoreProvider, + kbqAgGridRowGroupSelectionStateStoreProvider } from '../src/row-group.ng'; /** Shared row id extractor bound on every test host below — resolves each row's `id` field. */ @@ -295,6 +307,74 @@ const setupWithGroups = async () => { return result; }; +@Component({ + selector: 'test-row-group-state-grid', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowGroup] +}) +class TestRowGroupStateGrid { + collapsedStateKey = 'row-group-state'; + store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + selectionStateKey: string | undefined = undefined; + selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + readonly rowData = signal[]>([]); + readonly groupCols = signal([]); + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowGroup); + protected readonly testRowId = testRowId; +} + +/** Mirrors `setup()`, but for `TestRowGroupStateGrid` — lets each test configure `collapsedStateKey`, + * `store`, and pre-gridReady `groupCols` (simulating the consumer having already restored + * grouping fields themselves, as the class-level `**Persisting collapsed/expanded state**` + * note recommends) before `emitGridReady()` fires. */ +const setupWithState = async (params: { + data?: Record[]; + key?: string; + store: KbqAgGridRowGroupCollapsedStateStore; + selectionKey?: string; + selectionStore?: KbqAgGridRowGroupSelectionStateStore; + groupCols?: string[]; + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type +}) => { + const { fixture } = await render(TestRowGroupStateGrid, { + componentProperties: { + collapsedStateKey: params.key ?? 'row-group-state', + store: params.store, + ...(params.selectionKey !== undefined ? { selectionStateKey: params.selectionKey } : {}), + ...(params.selectionStore ? { selectionStore: params.selectionStore } : {}) + } + }); + fixture.componentInstance.rowData.set(params.data ?? DATA); + if (params.groupCols) fixture.componentInstance.groupCols.set(params.groupCols); + // Propagate the two-way `[(kbqAgGridRowGroupCols)]` binding into the directive's own model + // signal before gridReady — the host/directive signals only sync during change detection. + fixture.detectChanges(); + const grid = fixture.componentInstance.grid(); + grid.emitGridReady(); + return { fixture, grid, directive: fixture.componentInstance.directive() }; +}; + describe(KbqAgGridRowGroup.name, () => { afterEach(() => { testColDefs = INITIAL_COL_DEFS; @@ -1875,4 +1955,632 @@ describe(KbqAgGridRowGroup.name, () => { expect(emitted.map((row) => String(row.id))).toEqual(['1']); }); }); + + describe('collapsed state persistence', () => { + it('restores collapsed paths from the store on init when groupCols already matches', async () => { + const storedPaths = [groupPath('USA')]; + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => storedPaths), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive } = await setupWithState({ + key: 'grid-row-group-state-1', + store, + groupCols: ['country'] + }); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-group-state-1'); + expect(directive.collapsedPaths()).toEqual(new Set(storedPaths)); + }); + }); + + it('falls back to the default top-level auto-collapse when the store returns null', async () => { + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-state-2', + store, + groupCols: ['country'] + }); + + await waitFor(() => expect(store.getItem).toHaveBeenCalledWith('grid-row-group-state-2')); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + expect(directive.collapsedPaths()).toEqual(new Set([groupPath('USA'), groupPath('GBR')])); + }); + + it('a restored path is inert once groupCols no longer matches what it was saved under', async () => { + const storedPaths = [groupPath('USA')]; + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => storedPaths), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + // groupCols is left at its default ([]) — simulates a consumer that restores + // collapsedPaths via the store but doesn't also restore the grouping fields + // themselves (see the class-level "Persisting collapsed/expanded state" note). + const { directive, grid } = await setupWithState({ key: 'grid-row-group-state-x', store }); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalled(); + expect(directive.collapsedPaths()).toEqual(new Set(storedPaths)); + }); + // With no matching grouping structure, rows pass through flat — the restored path + // has nothing to apply to. + expect(grid.mock.nodes.every((n) => !isGroupHeader(n.data))).toBe(true); + }); + + it('saves collapsedPaths to the store when a group is toggled', async () => { + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-state-3', + store, + groupCols: ['country'] + }); + // groupCols was already set at gridReady, so the default top-level auto-collapse + // (no stored value to restore) starts every top-level group collapsed — expand + // everything first so the toggle below has an unambiguous, single-path effect. + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + + directive.toggleCollapse(groupPath('USA')); + + await waitFor(() => { + expect(store.setItem).toHaveBeenCalledWith('grid-row-group-state-3', [groupPath('USA')]); + }); + }); + + it('persists an empty array when everything is expanded, rather than removing the item', async () => { + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-state-4', + store, + groupCols: ['country'] + }); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + directive.expandAll(); + + await waitFor(() => { + expect(store.setItem).toHaveBeenCalledWith('grid-row-group-state-4', []); + }); + expect(store.removeItem).not.toHaveBeenCalled(); + }); + + it('does not persist while ungrouped', async () => { + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + await setupWithState({ key: 'grid-row-group-state-5', store }); + + await waitFor(() => expect(store.getItem).toHaveBeenCalled()); + expect(store.setItem).not.toHaveBeenCalled(); + }); + + it('clearGroupColumns removes the stored state', async () => { + const store: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-state-6', + store, + groupCols: ['country'] + }); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + directive.clearGroupColumns(); + + expect(store.removeItem).toHaveBeenCalledWith('grid-row-group-state-6'); + }); + + it('supports async store methods', async () => { + const storedPaths = [groupPath('USA')]; + const store: KbqAgGridRowGroupCollapsedStateStore = { + // eslint-disable-next-line @typescript-eslint/promise-function-async + getItem: jest.fn(() => Promise.resolve(storedPaths)), + // eslint-disable-next-line @typescript-eslint/promise-function-async + setItem: jest.fn(() => Promise.resolve(undefined)), + // eslint-disable-next-line @typescript-eslint/promise-function-async + removeItem: jest.fn(() => Promise.resolve(undefined)) + }; + + const { directive } = await setupWithState({ + key: 'grid-row-group-state-7', + store, + groupCols: ['country'] + }); + + await waitFor(() => { + expect(directive.collapsedPaths()).toEqual(new Set(storedPaths)); + }); + }); + }); + + describe('selection persistence', () => { + const noopStateStore: KbqAgGridRowGroupCollapsedStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + it('restores selected row ids from the store on init, including native node selection', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => ['1']), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-selection-1', + store: noopStateStore, + selectionKey: 'grid-row-group-selection-1-sel', + selectionStore, + groupCols: ['country'] + }); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + expect(selectionStore.getItem).toHaveBeenCalledWith('grid-row-group-selection-1-sel'); + expect(directive.overallSelectionState()).toBe('indeterminate'); + + // The default top-level auto-collapse (see "collapsed state persistence") hides row + // '1' inside a collapsed "USA" group initially — expand to materialize it as an AG + // node and confirm the restored selection was synced onto it. + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + const row1 = grid.mock.nodes.find((n) => n.data.id === '1')!; + expect(row1.isSelected()).toBe(true); + }); + + it('saves selectedRowIds to the store when setRowSelected is called', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive } = await setupWithState({ + key: 'grid-row-group-selection-2', + store: noopStateStore, + selectionKey: 'grid-row-group-selection-2-sel', + selectionStore + }); + await waitFor(() => expect(selectionStore.getItem).toHaveBeenCalled()); + + directive.setRowSelected('1', true); + + await waitFor(() => { + expect(selectionStore.setItem).toHaveBeenCalledWith('grid-row-group-selection-2-sel', ['1']); + }); + }); + + it('removes stored selection once everything is deselected', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive } = await setupWithState({ + key: 'grid-row-group-selection-3', + store: noopStateStore, + selectionKey: 'grid-row-group-selection-3-sel', + selectionStore + }); + await waitFor(() => expect(selectionStore.getItem).toHaveBeenCalled()); + + directive.setRowSelected('1', true); + await waitFor(() => expect(selectionStore.setItem).toHaveBeenCalled()); + + directive.setRowSelected('1', false); + + await waitFor(() => { + expect(selectionStore.removeItem).toHaveBeenCalledWith('grid-row-group-selection-3-sel'); + }); + }); + + it('does not persist selection when no [kbqAgGridRowGroupSelectionState] key is bound', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + // selectionKey intentionally omitted — the directive's own default (undefined). + const { directive } = await setupWithState({ + key: 'grid-row-group-selection-6', + store: noopStateStore, + selectionStore + }); + + directive.setRowSelected('1', true); + + expect(selectionStore.getItem).not.toHaveBeenCalled(); + expect(selectionStore.setItem).not.toHaveBeenCalled(); + }); + + it('clearGroupColumns does not clear the persisted selection — selection is independent of grouping', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { directive, grid, fixture } = await setupWithState({ + key: 'grid-row-group-selection-4', + store: noopStateStore, + selectionKey: 'grid-row-group-selection-4-sel', + selectionStore, + groupCols: ['country'] + }); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + directive.setRowSelected('1', true); + await waitFor(() => + expect(selectionStore.setItem).toHaveBeenCalledWith('grid-row-group-selection-4-sel', ['1']) + ); + + // removeItem legitimately fires once already on mount (empty selection at init, + // before setRowSelected above) — clear that baseline call so the assertion below + // is only about what clearGroupColumns() itself does. + jest.mocked(selectionStore.removeItem).mockClear(); + + directive.clearGroupColumns(); + + expect(selectionStore.removeItem).not.toHaveBeenCalled(); + }); + + it('supports async selection store methods', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + // eslint-disable-next-line @typescript-eslint/promise-function-async + getItem: jest.fn(() => Promise.resolve(['1', '2'])), + // eslint-disable-next-line @typescript-eslint/promise-function-async + setItem: jest.fn(() => Promise.resolve(undefined)), + // eslint-disable-next-line @typescript-eslint/promise-function-async + removeItem: jest.fn(() => Promise.resolve(undefined)) + }; + + const { directive } = await setupWithState({ + key: 'grid-row-group-selection-5', + store: noopStateStore, + selectionKey: 'grid-row-group-selection-5-sel', + selectionStore, + groupCols: ['country'] + }); + + // DATA has 3 rows total; ids '1' and '2' (both 'USA') restored as selected — the + // third ('3', 'GBR') is not, so the overall selection is partial. + await waitFor(() => { + expect(directive.overallSelectionState()).toBe('indeterminate'); + }); + }); + + it('restores selection even when data arrives after gridReady, and notifies rowSelectionChanged once it does', async () => { + const selectionStore: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => ['1']), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { fixture } = await render(TestRowGroupStateGrid, { + componentProperties: { + collapsedStateKey: 'grid-row-group-selection-late', + store: noopStateStore, + selectionStateKey: 'grid-row-group-selection-late-sel', + selectionStore + } + }); + const directive = fixture.componentInstance.directive(); + const emitSpy = jest.spyOn(directive.rowSelectionChanged, 'emit'); + + // Emit gridReady BEFORE rowData is available — simulates an async HTTP response + // (e.g. devInjectRowData()) arriving after the grid has already initialized. + fixture.componentInstance.grid().emitGridReady(); + + // waitFor's macrotask-based polling flushes every pending microtask in between + // checks, so by the time this resolves onGridReady's queueMicrotask (which stages + // pendingRestoredSelectedIds) is guaranteed to have already run too. + await waitFor(() => expect(selectionStore.getItem).toHaveBeenCalled()); + expect(emitSpy).not.toHaveBeenCalled(); + + // Data arrives after the microtask (simulates the HTTP response resolving) + fixture.componentInstance.rowData.set(DATA); + fixture.detectChanges(); + + await waitFor(() => { + expect(emitSpy).toHaveBeenCalledTimes(1); + const [[emitted]] = emitSpy.mock.calls; + expect(emitted.map((row) => String(row.id))).toEqual(['1']); + }); + }); + }); +}); + +describe(KbqAgGridRowGroupCollapsedStateLocalStorageStore.name, () => { + const key = 'row-group-collapsed-local-storage-test-key'; + + afterEach(() => { + window.localStorage.removeItem(key); + }); + + it('getItem returns null when nothing is stored', () => { + const store = new KbqAgGridRowGroupCollapsedStateLocalStorageStore(); + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem stores the value as JSON, and getItem parses it back', () => { + const store = new KbqAgGridRowGroupCollapsedStateLocalStorageStore(); + const value = [groupPath('USA'), groupPath('GBR')]; + + store.setItem(key, value); + + expect(window.localStorage.getItem(key)).toBe(JSON.stringify(value)); + expect(store.getItem(key)).toEqual(value); + }); + + it('removeItem deletes the stored value', () => { + const store = new KbqAgGridRowGroupCollapsedStateLocalStorageStore(); + store.setItem(key, [groupPath('USA')]); + + store.removeItem(key); + + expect(window.localStorage.getItem(key)).toBeNull(); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem returns null when the stored value is not valid JSON', () => { + const store = new KbqAgGridRowGroupCollapsedStateLocalStorageStore(); + window.localStorage.setItem(key, 'not-json{{{'); + + expect(store.getItem(key)).toBeNull(); + }); +}); + +describe(KbqAgGridRowGroupCollapsedStateQueryParamsStore.name, () => { + const key = 'row-group-collapsed-query-params-test-key'; + let navigate: jest.Mock; + let store: KbqAgGridRowGroupCollapsedStateQueryParamsStore; + + beforeEach(() => { + navigate = jest.fn(() => Promise.resolve(true)); + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: { navigate } }] + }); + store = TestBed.inject(KbqAgGridRowGroupCollapsedStateQueryParamsStore); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('getItem returns null when the query param is absent', () => { + window.history.pushState({}, '', '/?other=1'); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem parses the value from the URL query string', () => { + const value = [groupPath('USA')]; + window.history.pushState({}, '', `/?${key}=${encodeURIComponent(JSON.stringify(value))}`); + + expect(store.getItem(key)).toEqual(value); + }); + + it('getItem returns null when the query param is not valid JSON', () => { + window.history.pushState({}, '', `/?${key}=not-json{{{`); + + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem navigates with the JSON-stringified value merged into query params', async () => { + const value = [groupPath('USA')]; + + await store.setItem(key, value); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: JSON.stringify(value) }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); + + it('removeItem navigates with the query param set to null', async () => { + await store.removeItem(key); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); +}); + +describe(kbqAgGridRowGroupCollapsedStateStoreProvider.name, () => { + it('binds the token to useClass when given a class', () => { + const provider = kbqAgGridRowGroupCollapsedStateStoreProvider(KbqAgGridRowGroupCollapsedStateQueryParamsStore); + + expect(provider).toEqual({ + provide: KBQ_AG_GRID_ROW_GROUP_COLLAPSED_STATE_STORE, + useClass: KbqAgGridRowGroupCollapsedStateQueryParamsStore + }); + }); + + it('binds the token to useValue when given an instance', () => { + const instance: KbqAgGridRowGroupCollapsedStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + const provider = kbqAgGridRowGroupCollapsedStateStoreProvider(instance); + + expect(provider).toEqual({ provide: KBQ_AG_GRID_ROW_GROUP_COLLAPSED_STATE_STORE, useValue: instance }); + }); + + it('resolves to the provided store instance through Angular DI', () => { + const instance: KbqAgGridRowGroupCollapsedStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + TestBed.configureTestingModule({ providers: [kbqAgGridRowGroupCollapsedStateStoreProvider(instance)] }); + + expect(TestBed.inject(KBQ_AG_GRID_ROW_GROUP_COLLAPSED_STATE_STORE)).toBe(instance); + }); +}); + +describe(KbqAgGridRowGroupSelectionStateLocalStorageStore.name, () => { + const key = 'row-group-selection-local-storage-test-key'; + + afterEach(() => { + window.localStorage.removeItem(key); + }); + + it('getItem returns null when nothing is stored', () => { + const store = new KbqAgGridRowGroupSelectionStateLocalStorageStore(); + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem stores the value as JSON, and getItem parses it back', () => { + const store = new KbqAgGridRowGroupSelectionStateLocalStorageStore(); + const value = ['1', '2']; + + store.setItem(key, value); + + expect(window.localStorage.getItem(key)).toBe(JSON.stringify(value)); + expect(store.getItem(key)).toEqual(value); + }); + + it('removeItem deletes the stored value', () => { + const store = new KbqAgGridRowGroupSelectionStateLocalStorageStore(); + store.setItem(key, ['1']); + + store.removeItem(key); + + expect(window.localStorage.getItem(key)).toBeNull(); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem returns null when the stored value is not valid JSON', () => { + const store = new KbqAgGridRowGroupSelectionStateLocalStorageStore(); + window.localStorage.setItem(key, 'not-json{{{'); + + expect(store.getItem(key)).toBeNull(); + }); +}); + +describe(KbqAgGridRowGroupSelectionStateQueryParamsStore.name, () => { + const key = 'row-group-selection-query-params-test-key'; + let navigate: jest.Mock; + let store: KbqAgGridRowGroupSelectionStateQueryParamsStore; + + beforeEach(() => { + navigate = jest.fn(() => Promise.resolve(true)); + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: { navigate } }] + }); + store = TestBed.inject(KbqAgGridRowGroupSelectionStateQueryParamsStore); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('getItem returns null when the query param is absent', () => { + window.history.pushState({}, '', '/?other=1'); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem parses the value from the URL query string', () => { + const value = ['1', '2']; + window.history.pushState({}, '', `/?${key}=${encodeURIComponent(JSON.stringify(value))}`); + + expect(store.getItem(key)).toEqual(value); + }); + + it('getItem returns null when the query param is not valid JSON', () => { + window.history.pushState({}, '', `/?${key}=not-json{{{`); + + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem navigates with the JSON-stringified value merged into query params', async () => { + const value = ['1']; + + await store.setItem(key, value); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: JSON.stringify(value) }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); + + it('removeItem navigates with the query param set to null', async () => { + await store.removeItem(key); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); +}); + +describe(kbqAgGridRowGroupSelectionStateStoreProvider.name, () => { + it('binds the token to useClass when given a class', () => { + const provider = kbqAgGridRowGroupSelectionStateStoreProvider(KbqAgGridRowGroupSelectionStateQueryParamsStore); + + expect(provider).toEqual({ + provide: KBQ_AG_GRID_ROW_GROUP_SELECTION_STATE_STORE, + useClass: KbqAgGridRowGroupSelectionStateQueryParamsStore + }); + }); + + it('binds the token to useValue when given an instance', () => { + const instance: KbqAgGridRowGroupSelectionStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + const provider = kbqAgGridRowGroupSelectionStateStoreProvider(instance); + + expect(provider).toEqual({ provide: KBQ_AG_GRID_ROW_GROUP_SELECTION_STATE_STORE, useValue: instance }); + }); + + it('resolves to the provided store instance through Angular DI', () => { + const instance: KbqAgGridRowGroupSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + TestBed.configureTestingModule({ providers: [kbqAgGridRowGroupSelectionStateStoreProvider(instance)] }); + + expect(TestBed.inject(KBQ_AG_GRID_ROW_GROUP_SELECTION_STATE_STORE)).toBe(instance); + }); }); diff --git a/packages/ag-grid-angular-theme/tests/row-selection-state.ng.spec.ts b/packages/ag-grid-angular-theme/tests/row-selection-state.ng.spec.ts new file mode 100644 index 0000000..c933809 --- /dev/null +++ b/packages/ag-grid-angular-theme/tests/row-selection-state.ng.spec.ts @@ -0,0 +1,429 @@ +import { Component, Directive, forwardRef, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { render, waitFor } from '@testing-library/angular'; +import { AgGridAngular } from 'ag-grid-angular'; +import { AgEventType, GridApi, IRowNode } from 'ag-grid-community'; +import { Subject } from 'rxjs'; +import { + KBQ_AG_GRID_ROW_SELECTION_STATE_STORE, + KbqAgGridRowSelectionState, + KbqAgGridRowSelectionStateLocalStorageStore, + KbqAgGridRowSelectionStateQueryParamsStore, + KbqAgGridRowSelectionStateStore, + kbqAgGridRowSelectionStateStoreProvider +} from '../src/row-selection-state.ng'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyEventHandler = (event?: any) => void; + +const makeNode = (id: string, selected = false): IRowNode => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + id, + isSelected: () => selected, + setSelected: (value: boolean) => (selected = value) + } as unknown as IRowNode; +}; + +const createApiMock = ( + nodes: IRowNode[] = [] +): { + api: GridApi; + dispatch: (eventName: AgEventType, event?: object) => void; +} => { + const listeners = new Map(); + + const api = { + addEventListener: jest.fn((eventName: AgEventType, handler: AnyEventHandler) => { + const eventListeners = listeners.get(eventName) ?? []; + eventListeners.push(handler); + listeners.set(eventName, eventListeners); + }), + removeEventListener: jest.fn((eventName: AgEventType, handler: AnyEventHandler) => { + const eventListeners = listeners.get(eventName) ?? []; + listeners.set( + eventName, + eventListeners.filter((listener) => listener !== handler) + ); + }), + forEachNode: jest.fn((callback: (node: IRowNode, index: number) => void) => { + nodes.forEach((node, index) => callback(node, index)); + }), + setNodesSelected: jest.fn(({ nodes: selectedNodes, newValue }: { nodes: IRowNode[]; newValue: boolean }) => { + selectedNodes.forEach((node) => node.setSelected(newValue)); + }), + getSelectedNodes: jest.fn(() => nodes.filter((node) => node.isSelected())), + deselectAll: jest.fn(() => nodes.forEach((node) => node.setSelected(false))) + }; + + const dispatch = (eventName: AgEventType, event?: object): void => { + const eventListeners = listeners.get(eventName) ?? []; + eventListeners.forEach((listener) => listener(event)); + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { api: api as unknown as GridApi, dispatch }; +}; + +@Directive({ + selector: 'ag-grid-angular', + standalone: true, + providers: [{ provide: AgGridAngular, useExisting: forwardRef(() => TestAgGridAngularStub) }] +}) +class TestAgGridAngularStub { + readonly gridReady = new Subject<{ api: GridApi }>(); + readonly api = createApiMock().api; + + emitGridReady(api: GridApi = this.api): void { + this.gridReady.next({ api }); + } +} + +@Component({ + selector: 'test-row-selection-state-grid', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowSelectionState] +}) +class TestRowSelectionStateGrid { + key = 'row-selection'; + store: KbqAgGridRowSelectionStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowSelectionState); +} + +describe(KbqAgGridRowSelectionState.name, () => { + it('restores saved selection from store on firstDataRendered', async () => { + const nodeA = makeNode('a'); + const nodeB = makeNode('b'); + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => ['b']), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock([nodeA, nodeB]); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-1', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-selection-1'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setNodesSelected).toHaveBeenCalledWith({ + nodes: [nodeB], + newValue: true, + source: 'api' + }); + }); + }); + + it('does not select anything when store returns null', async () => { + const nodeA = makeNode('a'); + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock([nodeA]); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-2', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + apiMock.dispatch('firstDataRendered'); + + await waitFor(() => { + expect(store.getItem).toHaveBeenCalledWith('grid-row-selection-2'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.setNodesSelected).not.toHaveBeenCalled(); + }); + }); + + it('saves selected row ids on non-api selectionChanged events', async () => { + const nodeA = makeNode('a', true); + const nodeB = makeNode('b'); + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock([nodeA, nodeB]); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-3', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('selectionChanged', expect.any(Function)); + }); + + apiMock.dispatch('selectionChanged', { source: 'rowClicked' }); + + await waitFor(() => { + expect(store.setItem).toHaveBeenCalledWith('grid-row-selection-3', ['a']); + }); + }); + + it('removes stored selection when selectionChanged produces no selected rows', async () => { + const nodeA = makeNode('a'); + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock([nodeA]); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-4', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('selectionChanged', expect.any(Function)); + }); + + apiMock.dispatch('selectionChanged', { source: 'rowClicked' }); + + await waitFor(() => { + expect(store.removeItem).toHaveBeenCalledWith('grid-row-selection-4'); + expect(store.setItem).not.toHaveBeenCalled(); + }); + }); + + it('ignores selectionChanged events triggered by api source', async () => { + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-5', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('selectionChanged', expect.any(Function)); + }); + + apiMock.dispatch('selectionChanged', { source: 'api' }); + + await waitFor(() => { + expect(store.setItem).not.toHaveBeenCalled(); + expect(store.removeItem).not.toHaveBeenCalled(); + }); + }); + + it('reset removes stored state and clears grid selection', async () => { + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-6', store } + }); + + fixture.componentInstance.directive().reset(); + + expect(store.removeItem).toHaveBeenCalledWith('grid-row-selection-6'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(fixture.componentInstance.grid().api.deselectAll).toHaveBeenCalled(); + }); + + it('removes all event listeners on destroy', async () => { + const store: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + const apiMock = createApiMock(); + + const { fixture } = await render(TestRowSelectionStateGrid, { + componentProperties: { key: 'grid-row-selection-7', store } + }); + + fixture.componentInstance.grid().emitGridReady(apiMock.api); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.addEventListener).toHaveBeenCalledWith('selectionChanged', expect.any(Function)); + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-type-assertion + const addEventListenerMock = apiMock.api.addEventListener as unknown as jest.MockedFunction< + typeof apiMock.api.addEventListener + >; + + const getHandler = (eventName: AgEventType): AnyEventHandler | undefined => + addEventListenerMock.mock.calls.find(([name]) => name === eventName)?.[1]; + + fixture.destroy(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.removeEventListener).toHaveBeenCalledWith( + 'firstDataRendered', + getHandler('firstDataRendered') + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(apiMock.api.removeEventListener).toHaveBeenCalledWith( + 'selectionChanged', + getHandler('selectionChanged') + ); + }); +}); + +describe(KbqAgGridRowSelectionStateLocalStorageStore.name, () => { + const key = 'row-selection-local-storage-test-key'; + + afterEach(() => { + window.localStorage.removeItem(key); + }); + + it('getItem returns null when nothing is stored', () => { + const store = new KbqAgGridRowSelectionStateLocalStorageStore(); + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem stores the value as JSON, and getItem parses it back', () => { + const store = new KbqAgGridRowSelectionStateLocalStorageStore(); + const value = ['row-1', 'row-2']; + + store.setItem(key, value); + + expect(window.localStorage.getItem(key)).toBe(JSON.stringify(value)); + expect(store.getItem(key)).toEqual(value); + }); + + it('removeItem deletes the stored value', () => { + const store = new KbqAgGridRowSelectionStateLocalStorageStore(); + store.setItem(key, ['row-1']); + + store.removeItem(key); + + expect(window.localStorage.getItem(key)).toBeNull(); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem returns null when the stored value is not valid JSON', () => { + const store = new KbqAgGridRowSelectionStateLocalStorageStore(); + window.localStorage.setItem(key, 'not-json{{{'); + + expect(store.getItem(key)).toBeNull(); + }); +}); + +describe(KbqAgGridRowSelectionStateQueryParamsStore.name, () => { + const key = 'row-selection-query-params-test-key'; + let navigate: jest.Mock; + let store: KbqAgGridRowSelectionStateQueryParamsStore; + + beforeEach(() => { + navigate = jest.fn(() => Promise.resolve(true)); + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: { navigate } }] + }); + store = TestBed.inject(KbqAgGridRowSelectionStateQueryParamsStore); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('getItem returns null when the query param is absent', () => { + window.history.pushState({}, '', '/?other=1'); + expect(store.getItem(key)).toBeNull(); + }); + + it('getItem parses the value from the URL query string', () => { + const value = ['row-1', 'row-2']; + window.history.pushState({}, '', `/?${key}=${encodeURIComponent(JSON.stringify(value))}`); + + expect(store.getItem(key)).toEqual(value); + }); + + it('getItem returns null when the query param is not valid JSON', () => { + window.history.pushState({}, '', `/?${key}=not-json{{{`); + + expect(store.getItem(key)).toBeNull(); + }); + + it('setItem navigates with the JSON-stringified value merged into query params', async () => { + const value = ['row-1']; + + await store.setItem(key, value); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: JSON.stringify(value) }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); + + it('removeItem navigates with the query param set to null', async () => { + await store.removeItem(key); + + expect(navigate).toHaveBeenCalledWith([], { + queryParams: { [key]: null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + }); +}); + +describe(kbqAgGridRowSelectionStateStoreProvider.name, () => { + it('binds the token to useClass when given a class', () => { + const provider = kbqAgGridRowSelectionStateStoreProvider(KbqAgGridRowSelectionStateQueryParamsStore); + + expect(provider).toEqual({ + provide: KBQ_AG_GRID_ROW_SELECTION_STATE_STORE, + useClass: KbqAgGridRowSelectionStateQueryParamsStore + }); + }); + + it('binds the token to useValue when given an instance', () => { + const instance: KbqAgGridRowSelectionStateStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }; + + const provider = kbqAgGridRowSelectionStateStoreProvider(instance); + + expect(provider).toEqual({ provide: KBQ_AG_GRID_ROW_SELECTION_STATE_STORE, useValue: instance }); + }); + + it('resolves to the provided store instance through Angular DI', () => { + const instance: KbqAgGridRowSelectionStateStore = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn() + }; + + TestBed.configureTestingModule({ providers: [kbqAgGridRowSelectionStateStoreProvider(instance)] }); + + expect(TestBed.inject(KBQ_AG_GRID_ROW_SELECTION_STATE_STORE)).toBe(instance); + }); +});