diff --git a/dev/ag-grid-angular/src/main.ts b/dev/ag-grid-angular/src/main.ts index 83c8ac1..895afcb 100644 --- a/dev/ag-grid-angular/src/main.ts +++ b/dev/ag-grid-angular/src/main.ts @@ -78,6 +78,24 @@ if (isDevMode()) { path: 'row-actions', loadComponent: async () => import('./tests/row-actions.ng').then((m) => m.DevRowActions) }, + { + path: 'row-selection-state', + loadComponent: async () => import('./tests/row-selection-state.ng').then((m) => m.DevRowSelectionState) + }, + { + path: 'row-selection-state-query-params', + loadComponent: async () => + import('./tests/row-selection-state.ng').then((m) => m.DevRowSelectionStateQueryParams) + }, + { + path: 'row-focus-state', + loadComponent: async () => import('./tests/row-focus-state.ng').then((m) => m.DevRowFocusState) + }, + { + path: 'row-focus-state-query-params', + loadComponent: async () => + import('./tests/row-focus-state.ng').then((m) => m.DevRowFocusStateQueryParams) + }, { path: 'column-menu', loadComponent: async () => import('./tests/column-menu.ng').then((m) => m.DevColumnMenu) diff --git a/dev/ag-grid-angular/src/tests/row-focus-state.ng.ts b/dev/ag-grid-angular/src/tests/row-focus-state.ng.ts new file mode 100644 index 0000000..f5a98a5 --- /dev/null +++ b/dev/ag-grid-angular/src/tests/row-focus-state.ng.ts @@ -0,0 +1,100 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + KbqAgGridRowFocusStateLocalStorageStore, + KbqAgGridRowFocusStateQueryParamsStore, + KbqAgGridThemeModule +} from '@koobiq/ag-grid-angular-theme'; +import { AgGridModule } from 'ag-grid-angular'; +import { AllCommunityModule, ColDef, GetRowIdFunc, ModuleRegistry } from 'ag-grid-community'; +import { DevRowData, devInjectRowData } from '../row-data'; + +ModuleRegistry.registerModules([AllCommunityModule]); + +const COLUMN_DEFS: ColDef[] = [ + { field: 'athlete', headerName: 'Athlete' }, + { field: 'age', headerName: 'Age' }, + { field: 'country', headerName: 'Country' }, + { field: 'year', headerName: 'Year' }, + { field: 'date', headerName: 'Date' }, + { field: 'sport', headerName: 'Sport' } +]; + +const DEFAULT_COL_DEF: ColDef = { + editable: true +}; + +const GET_ROW_ID: GetRowIdFunc = (params) => params.data.id; + +const STATE_KEY = 'dev-ag-grid-row-focus-state'; + +@Component({ + standalone: true, + imports: [AgGridModule, KbqAgGridThemeModule], + selector: 'dev-row-focus-state', + template: ` + + + `, + styles: ` + ag-grid-angular { + height: 100%; + max-width: 2036px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DevRowFocusState { + readonly rowData = devInjectRowData(); + readonly store = inject(KbqAgGridRowFocusStateLocalStorageStore); + readonly stateKey = STATE_KEY; + readonly columnDefs = COLUMN_DEFS; + readonly defaultColDef = DEFAULT_COL_DEF; + readonly getRowId = GET_ROW_ID; +} + +@Component({ + standalone: true, + imports: [AgGridModule, KbqAgGridThemeModule], + selector: 'dev-row-focus-state-query-params', + template: ` + + + `, + styles: ` + ag-grid-angular { + height: 100%; + max-width: 2036px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DevRowFocusStateQueryParams { + readonly rowData = devInjectRowData(); + readonly store = inject(KbqAgGridRowFocusStateQueryParamsStore); + readonly stateKey = STATE_KEY; + readonly columnDefs = COLUMN_DEFS; + readonly defaultColDef = DEFAULT_COL_DEF; + readonly getRowId = GET_ROW_ID; +} diff --git a/dev/ag-grid-angular/src/tests/row-focus-state.playwright-spec.ts b/dev/ag-grid-angular/src/tests/row-focus-state.playwright-spec.ts new file mode 100644 index 0000000..4facdb0 --- /dev/null +++ b/dev/ag-grid-angular/src/tests/row-focus-state.playwright-spec.ts @@ -0,0 +1,105 @@ +import { expect, Page, test } from '@playwright/test'; +import { getCell, isCellFocused } from './utils/helpers'; + +const rowFocusStateStorageKey = 'dev-ag-grid-row-focus-state'; +const rowFocusStateQueryParamKey = 'dev-ag-grid-row-focus-state'; + +type StoredRowFocusState = { rowId: string; colId: string }; + +const clearRowFocusState = async (page: Page): Promise => { + await page.evaluate((key: string) => localStorage.removeItem(key), rowFocusStateStorageKey); +}; + +const getRowFocusState = async (page: Page): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return page.evaluate((key) => { + const stored = localStorage.getItem(key); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return stored ? JSON.parse(stored) : null; + }, rowFocusStateStorageKey); +}; + +const getRowFocusStateFromUrl = async (page: Page): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return page.evaluate((key) => { + const item = new URLSearchParams(window.location.search).get(key); + if (!item) return null; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(item); + } catch { + return null; + } + }, rowFocusStateQueryParamKey); +}; + +const buildStateUrl = (state: StoredRowFocusState): string => { + const encoded = encodeURIComponent(JSON.stringify(state)); + return `/e2e/row-focus-state-query-params?${rowFocusStateQueryParamKey}=${encoded}`; +}; + +test.describe('KbqAgGridRowFocusState', () => { + test.describe('KbqAgGridRowFocusStateLocalStorageStore', () => { + test('saves the active cell to localStorage when a cell is clicked', async ({ page }) => { + await page.goto('/e2e/row-focus-state'); + await clearRowFocusState(page); + + await getCell(page, 0, 'athlete').click(); + + await expect.poll(async () => (await getRowFocusState(page))?.colId).toBe('athlete'); + }); + + test('restores the active cell from localStorage on page reload', async ({ page }) => { + await page.goto('/e2e/row-focus-state'); + await clearRowFocusState(page); + + await getCell(page, 2, 'country').click(); + await expect.poll(async () => (await getRowFocusState(page))?.colId).toBe('country'); + + await page.reload(); + + await expect.poll(async () => isCellFocused(page, 2, 'country')).toBe(true); + }); + + test('reset clears the active cell and stored state', async ({ page }) => { + await page.goto('/e2e/row-focus-state'); + await clearRowFocusState(page); + + await getCell(page, 0, 'athlete').click(); + await expect.poll(async () => isCellFocused(page, 0, 'athlete')).toBe(true); + + await page.getByRole('button', { name: 'Reset state' }).click(); + + await expect.poll(async () => isCellFocused(page, 0, 'athlete')).toBe(false); + await expect.poll(async () => getRowFocusState(page)).toBeNull(); + }); + }); + + test.describe('KbqAgGridRowFocusStateQueryParamsStore', () => { + test('saves the active cell to URL when a cell is clicked', async ({ page }) => { + await page.goto('/e2e/row-focus-state-query-params'); + + await getCell(page, 0, 'athlete').click(); + + await expect.poll(async () => (await getRowFocusStateFromUrl(page))?.colId).toBe('athlete'); + }); + + test('restores the active cell from URL on page reload', async ({ page }) => { + await page.goto('/e2e/row-focus-state-query-params'); + + await getCell(page, 2, 'country').click(); + await expect.poll(async () => (await getRowFocusStateFromUrl(page))?.colId).toBe('country'); + + await page.reload(); + + await expect.poll(async () => isCellFocused(page, 2, 'country')).toBe(true); + }); + + test('applies pre-existing active cell from URL on page load', async ({ page }) => { + await page.goto(buildStateUrl({ rowId: '4f32fc42-54ea-4afe-bcb3-d3e15fff11a5', colId: 'athlete' })); + + await expect.poll(async () => isCellFocused(page, 0, 'athlete')).toBe(true); + }); + }); +}); diff --git a/dev/ag-grid-angular/src/tests/row-group.playwright-spec.ts b/dev/ag-grid-angular/src/tests/row-group.playwright-spec.ts index a8d9136..e74512a 100644 --- a/dev/ag-grid-angular/src/tests/row-group.playwright-spec.ts +++ b/dev/ag-grid-angular/src/tests/row-group.playwright-spec.ts @@ -51,6 +51,35 @@ const naturalCompare = (a: string, b: string): number => a.localeCompare(b, unde const getUseCustomCellContentCheckbox = (page: Page): Locator => page.getByTestId('useCustomCellContentCheckbox'); +const rowGroupCollapsedStateStorageKey = 'dev-ag-grid-row-group-collapsed-state'; +const rowGroupSelectionStateStorageKey = 'dev-ag-grid-row-group-selection-state'; + +const clearRowGroupPersistedState = async (page: Page): Promise => { + await page.evaluate( + ({ collapseKey, selectionKey }) => { + localStorage.removeItem(collapseKey); + localStorage.removeItem(selectionKey); + }, + { collapseKey: rowGroupCollapsedStateStorageKey, selectionKey: rowGroupSelectionStateStorageKey } + ); +}; + +const getStoredCollapsedPaths = async (page: Page): Promise => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + page.evaluate((key) => { + const stored = localStorage.getItem(key); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return stored ? JSON.parse(stored) : null; + }, rowGroupCollapsedStateStorageKey); + +const getStoredSelectedIds = async (page: Page): Promise => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + page.evaluate((key) => { + const stored = localStorage.getItem(key); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return stored ? JSON.parse(stored) : null; + }, rowGroupSelectionStateStorageKey); + // The custom kbqAgGridRowGroupCellContent demo (DevRowGroupCustomCellContent) renders its own // count markup instead of the default `.kbq-ag-grid-group-cell-renderer__count` span. const getCustomCellContentCount = (page: Page, rowIndex: number): Locator => @@ -723,4 +752,91 @@ test.describe('KbqAgGridRowGroup', () => { await expect(getGroupCellInner(page, 0)).toHaveAttribute('aria-expanded', 'false'); }); }); + + test.describe('state persistence', () => { + test('persists collapse state to localStorage and restores it on reload', async ({ page }) => { + await page.goto('/e2e/row-group'); + await clearRowGroupPersistedState(page); + await page.reload(); + await waitForGroupsVisible(page); + + // Expand the first top-level group — row 1 becomes its first (still-collapsed) + // nested Sport sub-group, not the *next* top-level country header. + const firstGroupKey = + (await page.locator('.kbq-ag-grid-group-cell-renderer__key').first().textContent()) ?? ''; + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + await expect.poll(async () => getStoredCollapsedPaths(page)).not.toBeNull(); + + await page.reload(); + await waitForGroupsVisible(page); + + // The same top-level group must still be expanded after reload. + await expect(getGroupCellInner(page, 0)).toContainText(firstGroupKey); + await expect(getGroupCellInner(page, 0)).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + }); + + test('reset group state clears the persisted collapse state', async ({ page }) => { + await page.goto('/e2e/row-group'); + await clearRowGroupPersistedState(page); + await page.reload(); + await waitForGroupsVisible(page); + + await getGroupCellInner(page, 0).click(); + await expect.poll(async () => getStoredCollapsedPaths(page)).not.toBeNull(); + + await page.getByTestId('resetRowGroupStateBtn').click(); + + await expect.poll(async () => getStoredCollapsedPaths(page)).toBeNull(); + }); + + test('persists selection to localStorage and restores it on reload, even hidden inside a collapsed group', async ({ + page + }) => { + await page.goto('/e2e/row-group'); + await clearRowGroupPersistedState(page); + await page.reload(); + await waitForGroupsVisible(page); + + // Select Ilya Zakharov while Russia > Diving is still fully collapsed. + await page.getByTestId('selectIlyaZakharovBtn').click(); + await expect(page.getByTestId('selectedCount')).toHaveText('1'); + await expect.poll(async () => getStoredSelectedIds(page)).not.toBeNull(); + + await page.reload(); + await waitForGroupsVisible(page); + + // The full-dataset selection count is restored — including a row not yet loaded + // into AG's row model — once the (async-fetched) row data has arrived. + await expect(page.getByTestId('selectedCount')).toHaveText('1', { timeout: 10_000 }); + + // Expand down to the row — it comes back visually selected too. + await page.getByTestId('expandRussiaDivingBtn').click(); + const athleteRow = getDataRowByText(page, 'Ilya Zakharov'); + await expect(athleteRow).toBeVisible(); + await expect(athleteRow).toHaveClass(/ag-row-selected/); + }); + + test('reset group state does not clear the persisted selection — selection is independent of grouping', async ({ + page + }) => { + await page.goto('/e2e/row-group'); + await clearRowGroupPersistedState(page); + await page.reload(); + await waitForGroupsVisible(page); + + await page.getByTestId('selectIlyaZakharovBtn').click(); + await expect.poll(async () => getStoredSelectedIds(page)).not.toBeNull(); + + await page.getByTestId('resetRowGroupStateBtn').click(); + + // Collapse state is cleared by the reset... + await expect.poll(async () => getStoredCollapsedPaths(page)).toBeNull(); + // ...but the selection itself — both in storage and in the reported count — remains. + await expect.poll(async () => getStoredSelectedIds(page)).not.toBeNull(); + await expect(page.getByTestId('selectedCount')).toHaveText('1'); + }); + }); }); diff --git a/dev/ag-grid-angular/src/tests/row-group.ts b/dev/ag-grid-angular/src/tests/row-group.ts index 827158d..2f06a9e 100644 --- a/dev/ag-grid-angular/src/tests/row-group.ts +++ b/dev/ag-grid-angular/src/tests/row-group.ts @@ -1,11 +1,13 @@ -import { ChangeDetectionStrategy, Component, computed, input, model, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, model, signal, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { KbqAgGridRowGroup, KbqAgGridRowGroupCellContent, + KbqAgGridRowGroupCollapsedStateLocalStorageStore, kbqAgGridRowGroupColOptionsProvider, KbqAgGridRowGroupInfo, KbqAgGridRowGroupRowId, + KbqAgGridRowGroupSelectionStateLocalStorageStore, KbqAgGridThemeModule } from '@koobiq/ag-grid-angular-theme'; import { AgGridModule } from 'ag-grid-angular'; @@ -101,6 +103,9 @@ class DevRowGroupCustomCellContent implements KbqAgGridRowGroupCellContent { +