diff --git a/dev/ag-grid-angular/src/main.ts b/dev/ag-grid-angular/src/main.ts index 6e7ba49..83c8ac1 100644 --- a/dev/ag-grid-angular/src/main.ts +++ b/dev/ag-grid-angular/src/main.ts @@ -102,6 +102,10 @@ if (isDevMode()) { { path: 'infinite-selection', loadComponent: async () => import('./tests/infinite-selection.ng').then((m) => m.DevInfiniteSelection) + }, + { + path: 'row-group', + loadComponent: async () => import('./tests/row-group').then((m) => m.DevRowGroup) } ] }; diff --git a/dev/ag-grid-angular/src/overview.ng.ts b/dev/ag-grid-angular/src/overview.ng.ts index 4256976..9a7d4bc 100644 --- a/dev/ag-grid-angular/src/overview.ng.ts +++ b/dev/ag-grid-angular/src/overview.ng.ts @@ -620,8 +620,6 @@ export class DevOverview { const { api } = event; this.gridApi = api; - - this.gridApi.setColumnWidths([{ key: 'ag-Grid-SelectionColumn', newWidth: 36 }]); } onFirstDataRendered(event: FirstDataRenderedEvent): void { diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-dark.png b/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-dark.png index 64632a9..cb0d60e 100644 Binary files a/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-dark.png and b/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-dark.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-light.png b/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-light.png index 150e4aa..be60f97 100644 Binary files a/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-light.png and b/dev/ag-grid-angular/src/tests/__screenshots__/column-menu-opened-light.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-dark.png b/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-dark.png index d94ad25..43751ad 100644 Binary files a/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-dark.png and b/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-dark.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-light.png b/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-light.png index 3681ed2..9b436b8 100644 Binary files a/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-light.png and b/dev/ag-grid-angular/src/tests/__screenshots__/infinite-selection-light.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/row-group-dark.png b/dev/ag-grid-angular/src/tests/__screenshots__/row-group-dark.png new file mode 100644 index 0000000..413a88b Binary files /dev/null and b/dev/ag-grid-angular/src/tests/__screenshots__/row-group-dark.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/row-group-light.png b/dev/ag-grid-angular/src/tests/__screenshots__/row-group-light.png new file mode 100644 index 0000000..ef8e1a4 Binary files /dev/null and b/dev/ag-grid-angular/src/tests/__screenshots__/row-group-light.png differ diff --git a/dev/ag-grid-angular/src/tests/__screenshots__/status-bar-light.png b/dev/ag-grid-angular/src/tests/__screenshots__/status-bar-light.png index b04f546..852a436 100644 Binary files a/dev/ag-grid-angular/src/tests/__screenshots__/status-bar-light.png and b/dev/ag-grid-angular/src/tests/__screenshots__/status-bar-light.png differ diff --git a/dev/ag-grid-angular/src/tests/column-state.playwright-spec.ts b/dev/ag-grid-angular/src/tests/column-state.playwright-spec.ts index 9f42184..ad74f8c 100644 --- a/dev/ag-grid-angular/src/tests/column-state.playwright-spec.ts +++ b/dev/ag-grid-angular/src/tests/column-state.playwright-spec.ts @@ -287,6 +287,16 @@ test.describe('KbqAgGridColumnState', () => { .locator('.ag-header-cell[col-id="athlete"]') .evaluate((el: Element) => Math.round(el.getBoundingClientRect().width)); + // The store persists to the URL asynchronously (router navigation) and isn't awaited + // by the resize handler — wait for it to land before reloading, or the reload can race + // ahead of the navigation and pick up the pre-resize width. + await expect + .poll(async () => { + const state = await getColumnStateFromUrl(page); + return state?.find((s) => s.colId === 'athlete')?.width; + }) + .toBe(widthBeforeReload); + await page.reload(); await expect 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 new file mode 100644 index 0000000..a8d9136 --- /dev/null +++ b/dev/ag-grid-angular/src/tests/row-group.playwright-spec.ts @@ -0,0 +1,726 @@ +import { expect, Locator, Page, test } from '@playwright/test'; +import { isRowSelected, waitForRowSelected } from './utils/helpers'; +import { enableDarkTheme } from './utils/theme'; + +const getScreenshotTarget = (page: Page): Locator => page.getByTestId('e2eScreenshotTarget'); + +const getGroupCheckbox = (page: Page, headerName: string): Locator => + page.locator('label').filter({ hasText: headerName }).locator('input[type="checkbox"]'); + +const getGroupCellInner = (page: Page, rowIndex: number): Locator => + page.locator(`.ag-row[row-index="${rowIndex}"] .kbq-ag-grid-group-cell-renderer__inner`); + +// Group rows render a custom checkbox (not AG's `.ag-checkbox-input`) in the selection column; +// AG Grid still emits its own native checkbox markup there too, permanently hidden via CSS. +// Matching on visibility picks whichever one is actually shown for the row. +const getRowCheckbox = (page: Page, rowIndex: number): Locator => + page.locator(`.ag-row[row-index="${rowIndex}"] input[type="checkbox"]:visible`); + +const waitForDataLoaded = async (page: Page): Promise => { + await expect(page.locator('.ag-row[row-index="0"]')).toBeVisible({ timeout: 10_000 }); +}; + +const waitForGroupsVisible = async (page: Page): Promise => { + await expect(getGroupCellInner(page, 0)).toBeVisible({ timeout: 5_000 }); +}; + +// Locates a group header row by its rendered key (e.g. "Russia" for a "Russia (706)" label). +const getGroupRowByKey = (page: Page, key: string): Locator => + page.locator('.ag-row').filter({ has: page.locator('.kbq-ag-grid-group-cell-renderer__key', { hasText: key }) }); + +// Locates a data (non-group) row containing the given text. +const getDataRowByText = (page: Page, text: string): Locator => + page + .locator('.ag-row') + .filter({ hasText: text }) + .filter({ hasNot: page.locator('.kbq-ag-grid-group-cell-renderer__inner') }); + +const getGroupHeaderCell = (page: Page): Locator => page.locator('.ag-header-cell[col-id="KbqAgGridRowGroup"]'); + +const getDataHeaderCell = (page: Page, colId: string): Locator => page.locator(`.ag-header-cell[col-id="${colId}"]`); + +// Every row (group or leaf) renders a cell for every column, but a group row's data-field cells +// are empty (group rows carry none of the raw data fields) — filtering those out leaves exactly +// the currently-visible leaf rows' Gold values, in DOM order. +const getLeafGoldValues = async (page: Page): Promise => { + const cells = await page.locator('.ag-row [col-id="gold"]').allTextContents(); + return cells.filter((text) => text !== '').map(Number); +}; + +const naturalCompare = (a: string, b: string): number => a.localeCompare(b, undefined, { numeric: true }); + +const getUseCustomCellContentCheckbox = (page: Page): Locator => page.getByTestId('useCustomCellContentCheckbox'); + +// 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 => + page.locator(`.ag-row[row-index="${rowIndex}"] .dev-row-group-custom-cell-content__count`); + +test.describe('KbqAgGridRowGroup', () => { + // Screenshots differ across OS — always update snapshots via Docker: `yarn run e2e:docker:update-snapshots` + test('screenshot — grouped, expanded, and with selection', async ({ page }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Expand Russia, then its Diving sub-group. + await getGroupRowByKey(page, 'Russia').locator('.kbq-ag-grid-group-cell-renderer__inner').click(); + const divingGroup = getGroupRowByKey(page, 'Diving'); + await expect(divingGroup).toBeVisible(); + await divingGroup.locator('.kbq-ag-grid-group-cell-renderer__inner').click(); + + // Select Ilya Zakharov's row. + const athleteRow = getDataRowByText(page, 'Ilya Zakharov'); + await expect(athleteRow).toBeVisible(); + await athleteRow.locator('input[type="checkbox"]:visible').click(); + await expect(athleteRow).toHaveClass(/ag-row-selected/); + + await expect(getScreenshotTarget(page)).toHaveScreenshot('row-group-light.png'); + await enableDarkTheme(page); + await expect(getScreenshotTarget(page)).toHaveScreenshot('row-group-dark.png'); + }); + + test('initial state shows grouped and collapsed rows from kbqAgGridRowGroupCols', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // All visible rows must be group headers — groups are collapsed by default + const totalRows = await page.locator('.ag-row').count(); + const groupRows = page.locator('.kbq-ag-grid-group-cell-renderer__inner'); + await expect(groupRows).toHaveCount(totalRows); + }); + + test('checking a column checkbox groups rows by that column', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + // Both columns are grouped by default (see DevRowGroup.groupCols) — start ungrouped so + // this test actually exercises "checking adds grouping", not just "grouping remains". + await getGroupCheckbox(page, 'Country').click(); + await getGroupCheckbox(page, 'Sport').click(); + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount(0, { timeout: 5_000 }); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // The first row should be a group header + await expect(getGroupCellInner(page, 0)).toBeVisible(); + }); + + test('clicking a group header expands it to show child rows', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // All groups are collapsed initially — no data rows visible beyond headers + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount( + await page.locator('.ag-row').count() + ); + + // Click first group to expand + await getGroupCellInner(page, 0).click(); + + // Row at index 1 should now be a data row (no group cell renderer inner) + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await expect(getGroupCellInner(page, 1)).toHaveCount(0); + }); + + test('clicking an expanded group header collapses it', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Expand first group + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await expect(getGroupCellInner(page, 1)).toHaveCount(0); + + // Collapse the same group + await getGroupCellInner(page, 0).click(); + + // Row 1 should now be the next group header (not a data row) + await expect(getGroupCellInner(page, 1)).toBeVisible(); + }); + + test('group header toggle is keyboard-operable via Enter and Space', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + const firstGroup = getGroupCellInner(page, 0); + await expect(firstGroup).toHaveAttribute('aria-expanded', 'false'); + + // Enter expands, same as a click. + await firstGroup.focus(); + await firstGroup.press('Enter'); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await expect(firstGroup).toHaveAttribute('aria-expanded', 'true'); + + // Space collapses it again. + await firstGroup.press(' '); + await expect(firstGroup).toHaveAttribute('aria-expanded', 'false'); + await expect(getGroupCellInner(page, 1)).toBeVisible(); + }); + + test('two group columns produce a nested group structure', async ({ page }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Expand the first top-level group + await getGroupCellInner(page, 0).click(); + + // Row 1 should be a nested group header (level 1) + await expect(getGroupCellInner(page, 1)).toBeVisible(); + + // Expand the nested group + await getGroupCellInner(page, 1).click(); + + // Row 2 should be a data row + await expect(page.locator('.ag-row[row-index="2"]')).toBeVisible(); + await expect(getGroupCellInner(page, 2)).toHaveCount(0); + }); + + test('selecting a group row selects all its expanded children', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Expand the first group so children are visible + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // Select the group header via checkbox + await getRowCheckbox(page, 0).click(); + + // All visible children should be selected + await waitForRowSelected(page, 1); + await waitForRowSelected(page, 2); + expect(await isRowSelected(page, 0)).toBe(true); + }); + + test('deselecting a group row deselects all its children', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // Select + await getRowCheckbox(page, 0).click(); + await waitForRowSelected(page, 1); + + // Deselect + await getRowCheckbox(page, 0).click(); + + await expect(page.locator('.ag-row[row-index="0"]')).not.toHaveClass(/ag-row-selected/); + await expect(page.locator('.ag-row[row-index="1"]')).not.toHaveClass(/ag-row-selected/); + }); + + test('selection is preserved when a collapsed group is expanded', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Select the first group header while it is still collapsed + await getRowCheckbox(page, 0).click(); + await waitForRowSelected(page, 0); + + // Expand the selected group + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // The group header must still be selected, and children must be auto-selected + await waitForRowSelected(page, 0); + await waitForRowSelected(page, 1); + }); + + test('deselecting one child unchecks the parent group but keeps all siblings selected', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Expand the first group so children are visible + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // Select the group header — all children become selected + await getRowCheckbox(page, 0).click(); + await waitForRowSelected(page, 1); + await waitForRowSelected(page, 2); + + // Deselect only the first child + await getRowCheckbox(page, 1).click(); + + // Parent must become unchecked, first child must be unchecked + await expect(page.locator('.ag-row[row-index="0"]')).not.toHaveClass(/ag-row-selected/); + await expect(page.locator('.ag-row[row-index="1"]')).not.toHaveClass(/ag-row-selected/); + + // Remaining siblings must still be selected + await waitForRowSelected(page, 2); + await waitForRowSelected(page, 3); + }); + + test('all visible children are selected after expanding a previously selected collapsed group', async ({ + page + }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Select the first group while it is still collapsed + await getRowCheckbox(page, 0).click(); + await waitForRowSelected(page, 0); + + // Expand the selected group + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // Every visible row must be selected — not just the first two + await waitForRowSelected(page, 0); + await waitForRowSelected(page, 1); + await waitForRowSelected(page, 2); + await waitForRowSelected(page, 3); + + const totalRows = await page.locator('.ag-row').count(); + const selectedRows = page.locator('.ag-row-selected'); + await expect(selectedRows).toHaveCount(totalRows); + }); + + test('preserves a partial selection when the group is collapsed and re-expanded', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + await getGroupCheckbox(page, 'Country').click(); + await waitForGroupsVisible(page); + + // Expand the first group so children are visible + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + + // Select only the first child — group ends up indeterminate, not itself selected + await getRowCheckbox(page, 1).click(); + await waitForRowSelected(page, 1); + await expect(page.locator('.ag-row[row-index="0"]')).not.toHaveClass(/ag-row-selected/); + + // Collapse the group — row 1 becomes the next group header again + await getGroupCellInner(page, 0).click(); + await expect(getGroupCellInner(page, 1)).toBeVisible(); + + // Re-expand — the same single child must still be selected, group still not fully checked + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await waitForRowSelected(page, 1); + await expect(page.locator('.ag-row[row-index="0"]')).not.toHaveClass(/ag-row-selected/); + }); + + test('two group levels: deselecting one sub-group unchecks parent but keeps siblings selected', async ({ + page + }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Expand the first top-level group + await getGroupCellInner(page, 0).click(); + await expect(getGroupCellInner(page, 1)).toBeVisible(); + + // Select the country group — all sport sub-groups become selected + await getRowCheckbox(page, 0).click(); + await waitForRowSelected(page, 1); + await waitForRowSelected(page, 2); + + // Deselect the first sport sub-group + await getRowCheckbox(page, 1).click(); + + // Country group must become unchecked, first sport must be unchecked + await expect(page.locator('.ag-row[row-index="0"]')).not.toHaveClass(/ag-row-selected/); + await expect(page.locator('.ag-row[row-index="1"]')).not.toHaveClass(/ag-row-selected/); + + // Remaining sport siblings must still be selected + await waitForRowSelected(page, 2); + await waitForRowSelected(page, 3); + }); + + test('kbqAgGridRowGroupSelectionChanged reports the full selection across collapsed groups, not just visible rows', async ({ + page + }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Select the whole top-level group while it — and all its nested sub-groups — are + // still collapsed, i.e. none of its descendant rows are loaded into AG's row model. + await getRowCheckbox(page, 0).click(); + + // Read back the reported total straight from selectedCount instead of parsing a group + // cell's own label — the default cell renderer no longer displays a descendant count. + const selectedCount = page.getByTestId('selectedCount'); + await expect(selectedCount).not.toHaveText('0'); + const total = Number(await selectedCount.textContent()); + + // Expand the top-level group, then its first nested sub-group. + await getGroupCellInner(page, 0).click(); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await getGroupCellInner(page, 1).click(); + await expect(page.locator('.ag-row[row-index="2"]')).toBeVisible(); + + // Deselect a single visible child row inside the now-expanded sub-group. + await getRowCheckbox(page, 2).click(); + + // The reported selection must drop by exactly one across the whole dataset — not + // collapse down to only the small sub-group's own visible count. + await expect(page.getByTestId('selectedCount')).toHaveText(String(total - 1)); + }); + + test('expandAll expands every group at every level', async ({ page }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('expandAllBtn').click(); + + // Row 0: top-level group header. Row 1: nested group header — both levels are + // expanded by a single call, without expanding each chevron by hand. + await expect(getGroupCellInner(page, 0)).toBeVisible(); + await expect(getGroupCellInner(page, 1)).toBeVisible(); + // Row 2: a plain data row — the deepest level has no further grouping. + await expect(page.locator('.ag-row[row-index="2"]')).toBeVisible(); + await expect(getGroupCellInner(page, 2)).toHaveCount(0); + }); + + test('collapseAll collapses every group back down to the top level', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('expandAllBtn').click(); + await expect(page.locator('.ag-row[row-index="2"]')).toBeVisible(); + + await page.getByTestId('collapseAllBtn').click(); + + // Back to the initial state: every currently visible row is a collapsed group header. + const totalRows = await page.locator('.ag-row').count(); + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount(totalRows); + }); + + test('setExpanded(groupPath, true) auto-expands every ancestor of a nested group', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('expandRussiaDivingBtn').click(); + + // Russia and its Diving sub-group are both expanded by one call — an athlete two + // levels deep becomes visible with no manual chevron clicks at all. + await expect(getDataRowByText(page, 'Ilya Zakharov')).toBeVisible(); + + // A sibling sport under Russia must stay collapsed — only the addressed path expands. + const gymnasticsGroup = getGroupRowByKey(page, 'Gymnastics'); + await expect(gymnasticsGroup).toBeVisible(); + await expect(gymnasticsGroup.locator('.kbq-chevron-right_16')).toBeVisible(); + }); + + test('setExpanded(groupPath, false) collapses only the target group, ancestors stay expanded', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('expandRussiaDivingBtn').click(); + await expect(getDataRowByText(page, 'Ilya Zakharov')).toBeVisible(); + + await page.getByTestId('collapseRussiaDivingBtn').click(); + + // Diving's own children are hidden again... + await expect(getDataRowByText(page, 'Ilya Zakharov')).toHaveCount(0); + // ...but Russia itself is still expanded, and the Diving group header is still shown. + await expect(getGroupRowByKey(page, 'Diving')).toBeVisible(); + }); + + test('setRowSelected selects a row hidden inside a collapsed group, and it shows selected once expanded', async ({ + page + }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Russia > Diving is still fully collapsed — no AG row node exists yet for this row. + await page.getByTestId('selectIlyaZakharovBtn').click(); + + // The selection is already reflected in the full-dataset count even though the row + // itself isn't loaded into AG's row model. + await expect(page.getByTestId('selectedCount')).toHaveText('1'); + + // Expand down to the row — it comes back selected. + await page.getByTestId('expandRussiaDivingBtn').click(); + const athleteRow = getDataRowByText(page, 'Ilya Zakharov'); + await expect(athleteRow).toBeVisible(); + await expect(athleteRow).toHaveClass(/ag-row-selected/); + }); + + test('setRowSelected(id, false) deselects a row', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('selectIlyaZakharovBtn').click(); + await expect(page.getByTestId('selectedCount')).toHaveText('1'); + + await page.getByTestId('deselectIlyaZakharovBtn').click(); + await expect(page.getByTestId('selectedCount')).toHaveText('0'); + + await page.getByTestId('expandRussiaDivingBtn').click(); + const athleteRow = getDataRowByText(page, 'Ilya Zakharov'); + await expect(athleteRow).toBeVisible(); + await expect(athleteRow).not.toHaveClass(/ag-row-selected/); + }); + + test('clicking the group column header cycles asc/desc/none, reordering top-level groups', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + const headerCell = getGroupHeaderCell(page); + const headerLabel = headerCell.locator('.ag-header-cell-label'); + const firstGroupKey = page.locator('.kbq-ag-grid-group-cell-renderer__key').first(); + + const initialFirstKey = (await firstGroupKey.textContent()) ?? ''; + + // Ascending. AG's own header state (aria-sort) updates synchronously on click, but the + // actual row reorder happens asynchronously (via the directive's `sortChanged` + // subscription → signal write → effect) — wait for the row content itself to change + // before reading it, not just the header attribute, to avoid a race between the two. + await headerLabel.click(); + await expect(headerCell).toHaveAttribute('aria-sort', 'ascending'); + await expect(firstGroupKey).not.toHaveText(initialFirstKey); + const ascFirstKey = (await firstGroupKey.textContent()) ?? ''; + + // Descending — the previous (ascending) first key must now sort strictly before the new + // first key, proving the whole order actually reversed rather than staying put. + await headerLabel.click(); + await expect(headerCell).toHaveAttribute('aria-sort', 'descending'); + await expect(firstGroupKey).not.toHaveText(ascFirstKey); + const descFirstKey = (await firstGroupKey.textContent()) ?? ''; + expect(naturalCompare(ascFirstKey, descFirstKey)).toBeLessThan(0); + + // Back to unsorted — original insertion order is restored. + await headerLabel.click(); + await expect(headerCell).toHaveAttribute('aria-sort', 'none'); + await expect(firstGroupKey).toHaveText(initialFirstKey); + }); + + test('setGroupColSort applies sort through AG Grid, keeping the header arrow in sync', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + const headerCell = getGroupHeaderCell(page); + const firstGroupKey = page.locator('.kbq-ag-grid-group-cell-renderer__key').first(); + const initialFirstKey = (await firstGroupKey.textContent()) ?? ''; + + await page.getByTestId('sortGroupColAscBtn').click(); + + // The header's own sort arrow reflects the programmatic change exactly as it would a + // real click, and the rows are reordered ascending. + await expect(headerCell).toHaveAttribute('aria-sort', 'ascending'); + await expect(firstGroupKey).not.toHaveText(initialFirstKey); + + await page.getByTestId('clearGroupColSortBtn').click(); + + await expect(headerCell).toHaveAttribute('aria-sort', 'none'); + await expect(firstGroupKey).toHaveText(initialFirstKey); + }); + + test('unchecking all column checkboxes removes grouping', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + // Both columns are grouped by default (see DevRowGroup.groupCols) — uncheck each + await getGroupCheckbox(page, 'Country').click(); + await getGroupCheckbox(page, 'Sport').click(); + + // Group cell renderers should no longer be present + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount(0, { timeout: 5_000 }); + }); + + test('sorting a data column reorders leaf rows within a group without scattering group headers', async ({ + page + }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await page.getByTestId('expandRussiaDivingBtn').click(); + await expect(getDataRowByText(page, 'Ilya Zakharov')).toBeVisible(); + + const groupCountBefore = await page.locator('.kbq-ag-grid-group-cell-renderer__inner').count(); + const goldBefore = await getLeafGoldValues(page); + + const goldHeader = getDataHeaderCell(page, 'gold'); + await goldHeader.locator('.ag-header-cell-label').click(); + await expect(goldHeader).toHaveAttribute('aria-sort', 'ascending'); + + // Group headers/nesting stay identical to before the sort — this is the actual bug: a + // native AG sort over rowData used to scatter/duplicate/lose these synthetic rows. + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount(groupCountBefore); + await expect(getGroupRowByKey(page, 'Diving')).toBeVisible(); + + // The leaf rows within Diving are now actually reordered ascending by Gold. + await expect.poll(async () => getLeafGoldValues(page)).not.toEqual(goldBefore); + const goldAfter = await getLeafGoldValues(page); + expect(goldAfter).toEqual([...goldAfter].sort((a, b) => a - b)); + }); + + test('sorting a data column and the group column are mutually exclusive', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + const groupHeader = getGroupHeaderCell(page); + const goldHeader = getDataHeaderCell(page, 'gold'); + + await groupHeader.locator('.ag-header-cell-label').click(); + await expect(groupHeader).toHaveAttribute('aria-sort', 'ascending'); + + // Sorting Gold takes over as the single active sort — AG itself clears the group + // column's sort state, same as a real header click would for any two native columns. + await goldHeader.locator('.ag-header-cell-label').click(); + await expect(goldHeader).toHaveAttribute('aria-sort', 'ascending'); + await expect(groupHeader).toHaveAttribute('aria-sort', 'none'); + }); + + test('data column sorting behaves as a plain native AG sort once grouping is removed', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + // Both columns are grouped by default (see DevRowGroup.groupCols) — uncheck each so the + // no-op comparator (grouped-only) is out of the picture entirely. + await getGroupCheckbox(page, 'Country').click(); + await getGroupCheckbox(page, 'Sport').click(); + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__inner')).toHaveCount(0, { timeout: 5_000 }); + + const goldHeader = getDataHeaderCell(page, 'gold'); + const goldBefore = await getLeafGoldValues(page); + + await goldHeader.locator('.ag-header-cell-label').click(); + await expect(goldHeader).toHaveAttribute('aria-sort', 'ascending'); + await expect.poll(async () => getLeafGoldValues(page)).not.toEqual(goldBefore); + const goldAsc = await getLeafGoldValues(page); + expect(goldAsc).toEqual([...goldAsc].sort((a, b) => a - b)); + + await goldHeader.locator('.ag-header-cell-label').click(); + await expect(goldHeader).toHaveAttribute('aria-sort', 'descending'); + await expect.poll(async () => getLeafGoldValues(page)).not.toEqual(goldAsc); + const goldDesc = await getLeafGoldValues(page); + expect(goldDesc).toEqual([...goldDesc].sort((a, b) => b - a)); + }); + + test('data columns for active group fields are hidden from header and body', async ({ page }) => { + // Both Country and Sport are grouped by default (see DevRowGroup.groupCols). + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await expect(getDataHeaderCell(page, 'country')).toHaveCount(0); + await expect(getDataHeaderCell(page, 'sport')).toHaveCount(0); + await expect(getDataHeaderCell(page, 'gold')).toBeVisible(); + await expect(page.locator('.ag-row [col-id="country"]')).toHaveCount(0); + }); + + test('un-grouping a field re-shows its data column', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + await expect(getDataHeaderCell(page, 'country')).toHaveCount(0); + + await getGroupCheckbox(page, 'Country').click(); + + await expect(getDataHeaderCell(page, 'country')).toBeVisible(); + await expect(getDataHeaderCell(page, 'sport')).toHaveCount(0); + }); + + test('adding a second group field while already grouped hides that column too', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForDataLoaded(page); + + // Start with only Country grouped (uncheck Sport, which is grouped by default). + await getGroupCheckbox(page, 'Sport').click(); + await waitForGroupsVisible(page); + await expect(getDataHeaderCell(page, 'country')).toHaveCount(0); + await expect(getDataHeaderCell(page, 'sport')).toBeVisible(); + + // Re-check Sport: a groupCols content change while already grouped, not the initial + // 0-to-non-zero boundary — the case syncGroupFieldVisibility exists to cover. + await getGroupCheckbox(page, 'Sport').click(); + + await expect(getDataHeaderCell(page, 'sport')).toHaveCount(0); + await expect(getDataHeaderCell(page, 'country')).toHaveCount(0); + }); + + test.describe('kbqAgGridRowGroupCellContent', () => { + test('renders the default key markup when no custom cell content is configured', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__key').first()).toBeVisible(); + await expect(getCustomCellContentCount(page, 0)).toHaveCount(0); + }); + + test('checking the toggle replaces the default markup with the custom component, live', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + const firstGroupKey = page.locator('.kbq-ag-grid-group-cell-renderer__key').first(); + const initialKey = (await firstGroupKey.textContent()) ?? ''; + + await getUseCustomCellContentCheckbox(page).click(); + + // Default key/count spans are gone — replaced by the custom component's own markup — + // and this happens immediately for already-rendered rows, without needing to + // re-expand/re-group anything. + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__key')).toHaveCount(0); + await expect(getCustomCellContentCount(page, 0)).toBeVisible(); + + // Same group, same key — only the rendering changed, not the underlying data. + await expect(getGroupCellInner(page, 0)).toContainText(initialKey); + }); + + test('unchecking the toggle reverts already-rendered rows back to the default markup', async ({ page }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await getUseCustomCellContentCheckbox(page).click(); + await expect(getCustomCellContentCount(page, 0)).toBeVisible(); + + await getUseCustomCellContentCheckbox(page).click(); + + await expect(getCustomCellContentCount(page, 0)).toHaveCount(0); + await expect(page.locator('.kbq-ag-grid-group-cell-renderer__key').first()).toBeVisible(); + }); + + test('expand/collapse via the toggle button keeps working when custom cell content is active', async ({ + page + }) => { + await page.goto('/e2e/row-group'); + await waitForGroupsVisible(page); + + await getUseCustomCellContentCheckbox(page).click(); + await expect(getCustomCellContentCount(page, 0)).toBeVisible(); + + // The chevron/click/keyboard toggle is unaffected by the custom cell content — only + // the key/count label area is swapped out (see KbqAgGridRowGroupCellRenderer). + await expect(getGroupCellInner(page, 0)).toHaveAttribute('aria-expanded', 'false'); + await getGroupCellInner(page, 0).click(); + await expect(getGroupCellInner(page, 0)).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.ag-row[row-index="1"]')).toBeVisible(); + await expect(getCustomCellContentCount(page, 1)).toBeVisible(); + + await getGroupCellInner(page, 0).click(); + await expect(getGroupCellInner(page, 0)).toHaveAttribute('aria-expanded', 'false'); + }); + }); +}); diff --git a/dev/ag-grid-angular/src/tests/row-group.ts b/dev/ag-grid-angular/src/tests/row-group.ts new file mode 100644 index 0000000..827158d --- /dev/null +++ b/dev/ag-grid-angular/src/tests/row-group.ts @@ -0,0 +1,192 @@ +import { ChangeDetectionStrategy, Component, computed, input, model, signal, viewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + KbqAgGridRowGroup, + KbqAgGridRowGroupCellContent, + kbqAgGridRowGroupColOptionsProvider, + KbqAgGridRowGroupInfo, + KbqAgGridRowGroupRowId, + KbqAgGridThemeModule +} from '@koobiq/ag-grid-angular-theme'; +import { AgGridModule } from 'ag-grid-angular'; +import { AllCommunityModule, ColDef, ModuleRegistry, SelectionChangedEvent } from 'ag-grid-community'; +import { devInjectRowData } from '../row-data'; + +ModuleRegistry.registerModules([AllCommunityModule]); + +const COLUMN_DEFS: ColDef[] = [ + { field: 'athlete', headerName: 'Athlete' }, + { field: 'country', headerName: 'Country' }, + { field: 'sport', headerName: 'Sport' }, + { field: 'year', headerName: 'Year' }, + { field: 'gold', headerName: 'Gold' }, + { field: 'silver', headerName: 'Silver' }, + { field: 'bronze', headerName: 'Bronze' }, + { field: 'total', headerName: 'Total' } +]; + +const DEFAULT_COL_DEF: ColDef = { + resizable: true, + minWidth: 80 +}; + +// Custom `kbqAgGridRowGroupCellContent` demo — replaces the default key markup with a +// differently-styled label; the toggle button/icon/click handling stays whatever the directive renders. +@Component({ + standalone: true, + selector: 'dev-row-group-custom-cell-content', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + {{ group().key }} + {{ group().count }} + `, + styles: ` + :host { + display: flex; + gap: var(--kbq-size-xxs); + } + + .dev-row-group-custom-cell-content__count { + color: var(--kbq-foreground-contrast-secondary); + } + ` +}) +class DevRowGroupCustomCellContent implements KbqAgGridRowGroupCellContent { + readonly group = input.required(); +} + +@Component({ + standalone: true, + imports: [AgGridModule, KbqAgGridThemeModule, FormsModule], + selector: 'dev-row-group', + template: ` +
+ Group by: + @for (col of columnDefs; track col.field) { + + } +
+ +
+ Selected rows: + {{ selectedRows().length }} +
+ +
+ + + + + + + + + +
+ + + `, + providers: [kbqAgGridRowGroupColOptionsProvider({ headerName: 'Group' })], + styles: ` + ag-grid-angular { + height: 500px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DevRowGroup { + readonly rowData = devInjectRowData(); + readonly columnDefs = COLUMN_DEFS; + readonly defaultColDef = DEFAULT_COL_DEF; + protected readonly useCustomCellContent = model(false); + protected readonly cellContent = computed(() => + this.useCustomCellContent() ? DevRowGroupCustomCellContent : undefined + ); + protected readonly rowId: KbqAgGridRowGroupRowId = (row) => String(row.id); + protected readonly groupCols = signal(['country', 'sport']); + protected readonly rowSelection = { mode: 'multiRow', checkboxes: true } as const; + protected readonly selectedRows = signal[]>([]); + private readonly group = viewChild.required(KbqAgGridRowGroup); + + protected expandAll(): void { + this.group().expandAll(); + } + + protected collapseAll(): void { + this.group().collapseAll(); + } + + protected expandRussiaDiving(): void { + this.group().setExpanded(['Russia', 'Diving'], true); + } + + protected collapseRussiaDiving(): void { + this.group().setExpanded(['Russia', 'Diving'], false); + } + + protected setIlyaZakharovSelected(selected: boolean): void { + const row = this.rowData().find((r) => r.athlete === 'Ilya Zakharov'); + if (row) this.group().setRowSelected(row.id, selected); + } + + protected sortGroupColAsc(): void { + this.group().setGroupColSort('asc'); + } + + protected clearGroupColSort(): void { + this.group().setGroupColSort(null); + } + + protected onToggle(field: string, event: Event): void { + const { target } = event; + if (!(target instanceof HTMLInputElement)) return; + const { checked } = target; + this.groupCols.update((cols) => (checked ? [...cols, field] : cols.filter((f) => f !== field))); + } + + protected onSelectionChanged(event: SelectionChangedEvent): void { + console.debug('SelectionChangedEvent: ', event); + } + + protected onRowSelectionChanged(rows: Record[]): void { + this.selectedRows.set(rows); + } +} diff --git a/dev/ag-grid-angular/src/tests/skeleton-cell-renderer.playwright-spec.ts b/dev/ag-grid-angular/src/tests/skeleton-cell-renderer.playwright-spec.ts index 2bb8ad5..59ee474 100644 --- a/dev/ag-grid-angular/src/tests/skeleton-cell-renderer.playwright-spec.ts +++ b/dev/ag-grid-angular/src/tests/skeleton-cell-renderer.playwright-spec.ts @@ -20,9 +20,14 @@ test.describe('DevSkeletonCellRenderer', () => { test('data rows appear after block loads', async ({ page }) => { await page.goto('/e2e/skeleton-cell-renderer'); await expect(getDataRows(page).first()).toBeVisible(); - // Global check fails — next unloaded block already renders skeleton cells. - // Scope to loaded rows only: skeleton cells inside rows with row-index must be gone. - await expect(page.locator('.ag-row[row-index] .kbq-ag-grid-skeleton-cell')).toHaveCount(0, { timeout: 5_000 }); + // The grid's viewport + row buffer renders more rows than a single cache block (9 rows), + // so later blocks' stub rows also carry `row-index` before their data arrives. + // Scope the check to block 0's rows (row-index 0-8), which are guaranteed loaded by now. + const firstBlockSkeletons = Array.from( + { length: 9 }, + (_, rowIndex) => `.ag-row[row-index="${rowIndex}"] .kbq-ag-grid-skeleton-cell` + ).join(', '); + await expect(page.locator(firstBlockSkeletons)).toHaveCount(0, { timeout: 5_000 }); }); test('subsequent blocks show skeletons while scrolling', async ({ page }) => { diff --git a/dev/ag-grid-angular/src/tests/theme.ng.ts b/dev/ag-grid-angular/src/tests/theme.ng.ts index 581ece2..6dcacf8 100644 --- a/dev/ag-grid-angular/src/tests/theme.ng.ts +++ b/dev/ag-grid-angular/src/tests/theme.ng.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { KbqAgGridThemeModule } from '@koobiq/ag-grid-angular-theme'; import { AgGridModule } from 'ag-grid-angular'; -import { AllCommunityModule, ColDef, GridReadyEvent, ModuleRegistry, RowSelectionOptions } from 'ag-grid-community'; +import { AllCommunityModule, ColDef, ModuleRegistry, RowSelectionOptions } from 'ag-grid-community'; import { devInjectRowData } from '../row-data'; ModuleRegistry.registerModules([AllCommunityModule]); @@ -35,7 +35,6 @@ const DEFAULT_COL_DEF: ColDef = { [rowSelection]="rowSelection" [defaultColDef]="defaultColDef" [pagination]="false" - (gridReady)="onGridReady($event)" /> `, styles: ` @@ -69,8 +68,4 @@ export class DevTheme { { field: 'bronze', headerName: 'Bronze' }, { field: 'total', headerName: 'Total' } ]; - - onGridReady({ api }: GridReadyEvent): void { - api.setColumnWidths([{ key: 'ag-Grid-SelectionColumn', newWidth: 36 }]); - } } diff --git a/packages/ag-grid-angular-theme/index.ts b/packages/ag-grid-angular-theme/index.ts index 984c985..150208c 100644 --- a/packages/ag-grid-angular-theme/index.ts +++ b/packages/ag-grid-angular-theme/index.ts @@ -8,6 +8,7 @@ export * from './src/loading-overlay.ng'; export * from './src/module.ng'; export * from './src/quick-filter-state.ng'; export * from './src/row-actions.ng'; +export * from './src/row-group.ng'; export * from './src/select-all-rows-by-ctrl-a.ng'; export * from './src/select-rows-by-ctrl-click.ng'; export * from './src/select-rows-by-shift-arrow.ng'; diff --git a/packages/ag-grid-angular-theme/src/module.ng.ts b/packages/ag-grid-angular-theme/src/module.ng.ts index 685c4bd..e7088ec 100644 --- a/packages/ag-grid-angular-theme/src/module.ng.ts +++ b/packages/ag-grid-angular-theme/src/module.ng.ts @@ -8,6 +8,7 @@ import { KbqAgGridInfiniteSelection } from './infinite-selection.ng'; import { KbqAgGridLoadingOverlay } from './loading-overlay.ng'; import { KbqAgGridQuickFilterState } from './quick-filter-state.ng'; import { KbqAgGridRowActions } from './row-actions.ng'; +import { KbqAgGridRowGroup } from './row-group.ng'; import { KbqAgGridSelectAllRowsByCtrlA } from './select-all-rows-by-ctrl-a.ng'; import { KbqAgGridSelectRowsByCtrlClick } from './select-rows-by-ctrl-click.ng'; import { KbqAgGridSelectRowsByShiftArrow } from './select-rows-by-shift-arrow.ng'; @@ -34,7 +35,8 @@ const COMPONENTS = [ KbqAgGridFilterState, KbqAgGridQuickFilterState, KbqAgGridExternalFilterState, - KbqAgGridLoadingOverlay + KbqAgGridLoadingOverlay, + KbqAgGridRowGroup ]; @NgModule({ diff --git a/packages/ag-grid-angular-theme/src/row-group.ng.ts b/packages/ag-grid-angular-theme/src/row-group.ng.ts new file mode 100644 index 0000000..482e150 --- /dev/null +++ b/packages/ag-grid-angular-theme/src/row-group.ng.ts @@ -0,0 +1,1631 @@ +import { NgComponentOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + Directive, + effect, + ElementRef, + inject, + InjectionToken, + input, + InputSignal, + isDevMode, + model, + output, + Provider, + signal, + Type, + untracked, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AgGridAngular, ICellRendererAngularComp, IHeaderAngularComp } from 'ag-grid-angular'; +import { + CellRendererSelectorResult, + CheckboxSelectionCallbackParams, + ColDef, + ColGroupDef, + ColumnState, + GridApi, + ICellRendererParams, + IHeaderParams, + IRowNode, + RowClassRules, + SelectionColumnDef, + SortDirection +} from 'ag-grid-community'; + +/** A plain input data row — any object with string keys. */ +type RowData = Record; + +/** Extracts a stable unique identifier from a data row, used to track selection across + * collapse/expand cycles. See `KbqAgGridRowGroup`'s `rowId` input. */ +export type KbqAgGridRowGroupRowId = (row: RowData) => string | number; + +/** Selection state of a row group or of the full dataset (see `overallSelectionState`). */ +export type KbqAgGridRowGroupSelectionState = 'checked' | 'unchecked' | 'indeterminate'; + +/** + * Ordered raw field values identifying a group and its ancestors — one value per active + * `kbqAgGridRowGroupCols` level, outermost first — e.g. `['Russia', 'Diving']` for + * `kbqAgGridRowGroupCols` `['country', 'sport']`. Passed to `KbqAgGridRowGroup.setExpanded`. + */ +export type KbqAgGridRowGroupPath = readonly unknown[]; + +/** `colId` of the generated group column, used to find it again in AG Grid's column state. */ +const GROUP_COL_ID = 'KbqAgGridRowGroup'; + +/** The single column currently driving native AG Grid sort UI — the group column or one data + * column, never both (AG Grid itself clears every other column's sort state on a plain header + * click; see the `sortChanged` subscription in `KbqAgGridRowGroup`). */ +type ActiveSort = { readonly colId: string; readonly sort: 'asc' | 'desc' }; + +/** Partial AG Grid `ColDef` overrides applied to the generated group column. */ +export type KbqAgGridRowGroupColOptions = Partial; + +/** Default group column options. */ +const KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS_DEFAULT: KbqAgGridRowGroupColOptions = { + headerName: 'Группа' +}; + +/** + * Injection token for supplying custom ColDef overrides for the generated group column. + * Defaults to `KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS_DEFAULT`. Not part of the public API — set + * options via `kbqAgGridRowGroupColOptionsProvider` instead. + */ +const KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS = new InjectionToken( + 'KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS', + { factory: (): KbqAgGridRowGroupColOptions => KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS_DEFAULT } +); + +/** + * Creates a provider that overrides the default group column options. + * + * @example + * ```ts + * providers: [kbqAgGridRowGroupColOptionsProvider({ headerName: 'Groups' })] + * ``` + */ +export const kbqAgGridRowGroupColOptionsProvider = (options: KbqAgGridRowGroupColOptions): Provider => ({ + provide: KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS, + useValue: options +}); + +/** A group header row rendered as an expand/collapse toggle. Contains no user data. */ +type GroupHeaderRow = { + readonly KbqAgGridRowGroup: { + readonly isGroup: true; + /** Nesting depth (0 = top level). */ + readonly level: number; + /** Unique path identifying this group — an opaque token (a JSON encoding of its + * ancestor chain internally); pass it to `isCollapsed`/`getGroupSelectionState`, don't + * parse or construct it yourself. */ + readonly path: string; + /** Paths of all ancestor groups. */ + readonly ancestors: readonly string[]; + /** Display value of this group. */ + readonly key: string; + /** Column field this group is based on. */ + readonly field: string; + /** Total number of descendant data rows. */ + readonly count: number; + }; +}; + +/** A data row. Contains original user data plus grouping metadata in `KbqAgGridRowGroup`. */ +type DataRow = RowData & { + readonly KbqAgGridRowGroup: { + readonly isGroup: false; + /** Nesting depth. */ + readonly level: number; + /** Paths of all ancestor groups. */ + readonly ancestors: readonly string[]; + /** Stable row identifier — see `KbqAgGridRowGroup`'s `rowId` input. */ + readonly id: string; + }; +}; + +/** Any row in the grouped dataset — either a group header or a data row. */ +type Row = GroupHeaderRow | DataRow; + +/** + * Data describing a single group header row, passed to a custom `kbqAgGridRowGroupCellContent` + * component via its `group` input. Carries more than the default renderer shows (which is just + * `key`) so a custom component can, for example, render `count` or `level`-dependent styling too. + */ +export type KbqAgGridRowGroupInfo = { + /** Nesting depth (0 = top level). */ + readonly level: number; + /** Column field this group is based on. */ + readonly field: string; + /** Display value of this group. */ + readonly key: string; + /** Total number of descendant data rows. */ + readonly count: number; + /** Unique path identifying this group — an opaque token (a JSON encoding of its ancestor + * chain internally); pass it to `isCollapsed`/`getGroupSelectionState`, don't parse or + * construct it yourself. */ + readonly path: string; + /** Paths of all ancestor groups. */ + readonly ancestors: readonly string[]; +}; + +/** + * Contract a custom `kbqAgGridRowGroupCellContent` component must satisfy — a single `group` + * input receiving the current group's info (see {@link KbqAgGridRowGroupInfo}). Rendered in place + * of the default key-only markup; the surrounding toggle button, chevron icon, and + * click/keyboard handling in `KbqAgGridRowGroupCellRenderer` are unaffected either way. + * + * @example + * ```ts + * @Component({ + * selector: 'my-group-label', + * template: `{{ group().key }} — {{ group().count }} items` + * }) + * class MyGroupLabel implements KbqAgGridRowGroupCellContent { + * readonly group = input.required(); + * } + * ``` + */ +export type KbqAgGridRowGroupCellContent = { + readonly group: InputSignal; +}; + +/** + * Canonical, collision-safe string for a single raw group value — used both as the bucketing + * key in `makeRowGroupData` and as one element of the JSON-encoded array a group's `path` is + * built from (see `makeRowGroupData`/`computeSubGroupPaths`, which join/split segments via + * `JSON.stringify`/`JSON.parse` rather than a hand-rolled separator). Delegates to + * `JSON.stringify` for anything JSON can represent, so e.g. the number `1` (`"1"`) and the + * string `"1"` (`'"1"'`) naturally encode differently — no hand-rolled `${typeof}:${value}` + * tagging that a string value could itself accidentally collide with. A few JSON-unsafe cases + * get an explicit sentinel instead: `undefined` has no JSON form at all, `NaN`/`±Infinity` would + * otherwise all collapse to the same `"null"`, and `bigint`/`function`/`symbol` would make + * `JSON.stringify` throw or silently return `undefined` (violating this function's `string` + * return type). Used everywhere group identity is compared: `makeRowGroupData`'s bucketing, + * `collapsedPaths` path segments, and `setExpanded`'s caller-supplied raw values — all funnel + * through this one function, so as long as it's applied consistently they stay comparable. + */ +const toKey = (value: unknown): string => { + if (value === undefined) return 'undefined'; + if (typeof value === 'bigint') return `bigint:${value}`; + if (typeof value === 'function' || typeof value === 'symbol') return `${typeof value}:${String(value)}`; + if (typeof value === 'number' && !Number.isFinite(value)) return `number:${value}`; + return JSON.stringify(value); +}; + +/** Human-readable label for a group's value, shown in the UI (see `KbqAgGridRowGroupCellRenderer`). */ +const toDisplayLabel = (value: unknown): string => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''; + +/** Map from a `RowData` object (by reference, from the unfiltered input array) to its resolved id. */ +type RowIdMap = ReadonlyMap; + +/** + * Resolves a stable id per row via `rowId`, falling back to array position when absent. + * Also reports any duplicate ids found, for the caller to warn about. + */ +function resolveRowIds( + data: readonly RowData[], + rowId: KbqAgGridRowGroupRowId | undefined +): { ids: RowIdMap; duplicateIds: ReadonlySet } { + const ids = new Map(); + const seen = new Set(); + const duplicateIds = new Set(); + + data.forEach((row, index) => { + const id = rowId ? String(rowId(row)) : String(index); + if (seen.has(id)) duplicateIds.add(id); + seen.add(id); + ids.set(row, id); + }); + + return { ids, duplicateIds }; +} + +/** The ancestor group paths for `row` given `groupFields`, shallowest first. Each path is the + * JSON encoding of the `toKey` segments up to that level — see `toKey`. */ +function computeRowAncestorPaths(row: RowData, groupFields: readonly string[]): string[] { + const paths: string[] = []; + const segments: string[] = []; + for (const field of groupFields) { + segments.push(toKey(row[field])); + paths.push(JSON.stringify(segments)); + } + return paths; +} + +/** Structural equality for the small maps used as Angular `computed()` equality checks. */ +function mapsEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a.size !== b.size) return false; + for (const [key, value] of a) { + if (b.get(key) !== value) return false; + } + return true; +} + +/** + * Sorts group `[key, rows]` entries by key — numeric-aware (via `Intl`-backed + * `localeCompare`), so a field like "year" orders `2 < 10` rather than lexicographically + * (`"10" < "2"`), matching what a user comparing displayed group labels would expect. + */ +function sortGroupEntries(groups: ReadonlyMap, sort: 'asc' | 'desc'): [string, RowData[]][] { + const direction = sort === 'asc' ? 1 : -1; + return [...groups].sort( + ([a], [b]) => direction * a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) + ); +} + +/** + * Recursively walks a `(ColDef | ColGroupDef)[]` tree, applying `transform` to every leaf + * `ColDef` — including ones nested under `ColGroupDef.children` — and returns a new tree. + * `ColGroupDef` nodes are shallow-cloned with their `children` replaced; they have no + * `field`/`comparator` of their own to transform. + */ +function mapColDefTree( + defs: readonly (ColDef | ColGroupDef)[], + transform: (def: ColDef) => ColDef +): (ColDef | ColGroupDef)[] { + return defs.map((def) => + 'children' in def ? { ...def, children: mapColDefTree(def.children, transform) } : transform(def) + ); +} + +/** + * Builds a `colId → field` map for every leaf `ColDef` in `defs`, recursing into + * `ColGroupDef.children` — `colId` defaults to `field` when not explicitly set, matching AG + * Grid's own default. Used to resolve which raw data field a native data-column sort (read from + * `api.getColumnState()`, which only reports `colId`) should reorder leaf rows by. Columns + * without a `field` (e.g. pure cellRenderer columns) are skipped — they can never be the target + * of a leaf-row sort. + */ +function collectDataColIdToField(defs: readonly (ColDef | ColGroupDef)[]): ReadonlyMap { + const map = new Map(); + for (const def of defs) { + if ('children' in def) { + for (const [colId, field] of collectDataColIdToField(def.children)) map.set(colId, field); + } else if (def.field) { + map.set(def.colId ?? def.field, def.field); + } + } + return map; +} + +/** + * What `makeRowGroupData` should sort — derived from `_activeSort` by + * `KbqAgGridRowGroup.resolveRowGroupDataSort`. At most one of `group`/`leaf` is ever non-null + * (AG Grid clears every other column's sort on a plain header click, so only one column can be + * the active sort at a time). `group` reorders group-header siblings at every nesting level + * (existing behavior, driven by the group column's own sort); `leaf`, when set, reorders the + * leaf data rows within each deepest group by a raw field value (a data column's sort). + */ +type RowGroupDataSort = { + readonly group: SortDirection; + readonly leaf: { readonly field: string; readonly direction: 'asc' | 'desc' } | null; +}; + +/** + * Default ordering for two raw field values when sorting leaf data rows within a group by a + * data column's field. Two numbers compare numerically; everything else falls back to a + * numeric-aware `localeCompare` (mirrors `sortGroupEntries`'s approach for group keys). + * + * Known limitation: this does NOT invoke any consumer-supplied ColDef `comparator` / + * `valueGetter` for the field — sorting always uses the row's raw property value. A column with + * custom display formatting/derivation whose sort order should follow that custom logic will + * not, while grouping is active. This is an explicit, documented simplification, not an + * oversight — see `sortLeafRows`. + */ +function compareRawValues(a: unknown, b: unknown): number { + if (typeof a === 'number' && typeof b === 'number') return a - b; + return toDisplayLabel(a).localeCompare(toDisplayLabel(b), undefined, { numeric: true, sensitivity: 'base' }); +} + +/** Sorts a copy of `data` by `leaf.field`'s raw value — see `compareRawValues` and its + * documented known limitation (no consumer comparator/valueGetter support). */ +function sortLeafRows(data: readonly RowData[], leaf: { field: string; direction: 'asc' | 'desc' }): RowData[] { + const direction = leaf.direction === 'asc' ? 1 : -1; + return [...data].sort((a, b) => direction * compareRawValues(a[leaf.field], b[leaf.field])); +} + +// eslint-disable-next-line @typescript-eslint/max-params +function makeRowGroupData( + data: RowData[], + groupFields: readonly string[], + rowIdMap: RowIdMap, + collapsedPaths: ReadonlySet = new Set(), + sort: RowGroupDataSort = { group: null, leaf: null }, + level = 0, + ancestors: readonly string[] = [], + pathSegments: readonly string[] = [] +): Row[] { + if (groupFields.length === 0) { + const rows = sort.leaf ? sortLeafRows(data, sort.leaf) : data; + return rows.map((row) => ({ + ...row, + KbqAgGridRowGroup: { isGroup: false as const, level, ancestors, id: rowIdMap.get(row) ?? '' } + })); + } + + const [currentField, ...remainingFields] = groupFields; + const groups = new Map(); + + for (const row of data) { + const key = toKey(row[currentField]); + const existing = groups.get(key); + if (existing) { + existing.push(row); + } else { + groups.set(key, [row]); + } + } + + const result: Row[] = []; + // `sort.group` only ever reorders group-header siblings at each level; `sort.leaf` (handled + // in the base case above) independently reorders leaf data rows within the deepest group — + // the two never apply to the same rows and are never both set at once. + const entries = sort.group ? sortGroupEntries(groups, sort.group) : groups; + + for (const [key, rows] of entries) { + const segments = [...pathSegments, key]; + const path = JSON.stringify(segments); + const collapsed = collapsedPaths.has(path); + const [firstRow] = rows; + + result.push({ + KbqAgGridRowGroup: { + isGroup: true, + level, + path, + ancestors, + key: toDisplayLabel(firstRow[currentField]), + field: currentField, + count: rows.length + } + }); + + if (!collapsed) { + result.push( + ...makeRowGroupData( + rows, + remainingFields, + rowIdMap, + collapsedPaths, + sort, + level + 1, + [...ancestors, path], + segments + ) + ); + } + } + + return result; +} + +type KbqAgGridRowGroupCellRendererParams = ICellRendererParams & { rowGroup: KbqAgGridRowGroup }; + +// KbqAgGridRowGroup is typed as always-present on Row, but flat (ungrouped) rows are passed +// through as raw RowData and genuinely lack it at runtime — hence the extra `?.` below. +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +const isGroupHeaderRow = (row: Row | null | undefined): row is GroupHeaderRow => !!row?.KbqAgGridRowGroup?.isGroup; + +@Component({ + standalone: true, + selector: 'kbq-ag-grid-group-cell-renderer', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'kbq-ag-grid-group-cell-renderer' }, + imports: [NgComponentOutlet], + template: ` + @if (isGroup()) { + + + + } + ` +}) +class KbqAgGridRowGroupCellRenderer implements ICellRendererAngularComp { + private groupState: KbqAgGridRowGroup | null = null; + protected readonly isGroup = signal(false); + protected readonly row = signal({ + KbqAgGridRowGroup: { isGroup: true, level: 0, path: '', ancestors: [], key: '', field: '', count: 0 } + }); + protected readonly indent = signal(8); + protected readonly collapsed = signal(false); + /** Custom component replacing the default key/count markup — see `kbqAgGridRowGroupCellContent`. */ + protected readonly cellContent = signal | undefined>(undefined); + /** `group` input value passed to `cellContent()` — derived from `row()`. */ + protected readonly groupInfo = computed(() => { + const { level, field, key, count, path, ancestors } = this.row().KbqAgGridRowGroup; + return { level, field, key, count, path, ancestors }; + }); + + agInit(params: KbqAgGridRowGroupCellRendererParams): void { + this.groupState = params.rowGroup; + const { data } = params; + const isGroup = isGroupHeaderRow(data); + // untracked: this method may be called from within an Angular effect context + // (e.g. when refreshCells is triggered from the refreshCells effect). Signal + // writes are not allowed inside effects without allowSignalWrites; untracked + // removes the reactive context so the writes succeed. + untracked(() => { + // Read live off the directive (rather than a value snapshotted into + // cellRendererParams at column-def creation time) so a runtime change to + // kbqAgGridRowGroupCellContent takes effect on the next refresh() — see the + // "redraw group rows on cellContent change" effect in KbqAgGridRowGroup. + this.cellContent.set(this.groupState?.cellContent()); + this.isGroup.set(isGroup); + if (isGroup) this.updateFromParams(data); + }); + } + + refresh(params: KbqAgGridRowGroupCellRendererParams): boolean { + this.groupState = params.rowGroup; + const { data } = params; + const isGroup = isGroupHeaderRow(data); + untracked(() => { + this.cellContent.set(this.groupState?.cellContent()); + this.isGroup.set(isGroup); + if (isGroup) this.updateFromParams(data); + }); + return true; + } + + protected toggle(): void { + this.groupState?.toggleCollapse(this.row().KbqAgGridRowGroup.path); + } + + private updateFromParams(row: GroupHeaderRow): void { + const { level, path } = row.KbqAgGridRowGroup; + this.row.set(row); + this.indent.set(level * 20 + 8); + this.collapsed.set(this.groupState?.isCollapsed(path) ?? false); + } +} + +@Component({ + standalone: true, + selector: 'kbq-ag-grid-group-selection-cell-renderer', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'kbq-ag-grid-group-selection-cell-renderer' }, + template: ` + @if (isGroup()) { + + + } + ` +}) +class KbqAgGridRowGroupSelectionCellRenderer implements ICellRendererAngularComp { + private groupState: KbqAgGridRowGroup | null = null; + private readonly checkboxRef = viewChild>('checkbox'); + protected readonly isGroup = signal(false); + protected readonly key = signal(''); + protected readonly selectionState = signal('unchecked'); + private path = ''; + + constructor() { + effect(() => { + const el = this.checkboxRef()?.nativeElement; + if (!el) return; + el.indeterminate = this.selectionState() === 'indeterminate'; + }); + } + + agInit(params: KbqAgGridRowGroupCellRendererParams): void { + this.groupState = params.rowGroup; + const { data } = params; + const isGroup = isGroupHeaderRow(data); + // untracked: see KbqAgGridRowGroupCellRenderer.agInit + untracked(() => { + this.isGroup.set(isGroup); + if (isGroup) this.updateFromParams(data); + }); + } + + refresh(params: KbqAgGridRowGroupCellRendererParams): boolean { + this.groupState = params.rowGroup; + const { data } = params; + const isGroup = isGroupHeaderRow(data); + untracked(() => { + this.isGroup.set(isGroup); + if (isGroup) this.updateFromParams(data); + }); + return true; + } + + protected onCheckboxClick(): void { + this.groupState?.onGroupCheckboxClick(this.path); + } + + private updateFromParams(row: GroupHeaderRow): void { + const { path, key } = row.KbqAgGridRowGroup; + this.path = path; + this.key.set(key); + this.selectionState.set(this.groupState?.getGroupSelectionState(path) ?? 'unchecked'); + } +} + +type KbqAgGridRowGroupHeaderCellRendererParams = IHeaderParams & { rowGroup: KbqAgGridRowGroup }; + +/** + * Custom "select all" header checkbox for the selection column. AG Grid's own header checkbox + * only knows about currently-*visible* rows, so it disables itself while any group is + * collapsed (nothing selectable is loaded into the row model at that point) — this renders + * instead, driven by `KbqAgGridRowGroup.overallSelectionState`, which accounts for the full, + * uncollapsed dataset. + */ +@Component({ + standalone: true, + selector: 'kbq-ag-grid-row-group-select-all-header-cell-renderer', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'kbq-ag-grid-row-group-select-all-header-cell-renderer' }, + template: ` + + + ` +}) +class KbqAgGridRowGroupSelectAllHeaderCellRenderer implements IHeaderAngularComp { + private rowGroup: KbqAgGridRowGroup | null = null; + private readonly checkboxRef = viewChild>('checkbox'); + + protected readonly selectionState = signal('unchecked'); + + constructor() { + effect(() => { + const el = this.checkboxRef()?.nativeElement; + if (!el) return; + el.indeterminate = this.selectionState() === 'indeterminate'; + }); + } + + agInit(params: KbqAgGridRowGroupHeaderCellRendererParams): void { + this.rowGroup = params.rowGroup; + this.updateFromRowGroup(); + } + + refresh(params: KbqAgGridRowGroupHeaderCellRendererParams): boolean { + this.rowGroup = params.rowGroup; + this.updateFromRowGroup(); + return true; + } + + protected onCheckboxClick(): void { + this.rowGroup?.onSelectAllCheckboxClick(); + } + + private updateFromRowGroup(): void { + // untracked: see KbqAgGridRowGroupCellRenderer.agInit — this may run inside the + // "refresh select-all header" effect (via api.refreshHeader()). + untracked(() => { + this.selectionState.set(this.rowGroup?.overallSelectionState() ?? 'unchecked'); + }); + } +} + +/** + * Directive that implements client-side row grouping without AG Grid Enterprise. + * + * Attach to `ag-grid-angular` and supply raw data via `[kbqAgGridRowGroupRowData]` + * instead of the usual `[rowData]`. The directive manages data transformation and + * exposes an API for controlling grouping programmatically. + * + * **Recommended:** + * - Set `[kbqAgGridRowGroupRowId]` to a function extracting a stable unique id from a row. + * Group selection (including a *partial* selection, shown as indeterminate) is tracked by + * row id so it survives collapsing and re-expanding a group. Without it, row identity + * falls back to array position, which only stays correct as long as + * `[kbqAgGridRowGroupRowData]` keeps the same order and content between a collapse and the + * matching expand. + * - Read `(kbqAgGridRowGroupSelectionChanged)` instead of AG Grid's own `(selectionChanged)` + * whenever the full selection matters — AG's event only reports rows currently materialized + * as row nodes, so it misses rows hidden inside a collapsed group. + * + * **Data column sorting while grouped:** + * - Clicking any data column's header while grouping is active reorders leaf rows *within each + * deepest group only* — group headers and nesting never move. Sorting a data column and + * sorting the Group column are mutually exclusive: activating one clears the other, exactly + * like any two native AG Grid columns. + * - The comparison is a generic numeric/locale-aware comparison of the field's raw value — it + * does NOT invoke a consumer-supplied ColDef `comparator` or `valueGetter`. A column whose + * display value is derived/formatted will sort by its underlying raw value instead, while + * grouping is active. + * - Only one column's sort is ever honored (no multi-column/shift-click sort chaining), and + * there is no way to sort group headers themselves by an aggregate of a data field. + * + * **Grouped column visibility:** + * - A column whose field is an active `groupCols` entry has its own data cells hidden from the + * grid body — its value is only shown via the synthetic Group column instead, matching real AG + * Grid Enterprise's row-grouping UX. It's automatically restored once the field is no longer + * grouped. + * - A column already hidden independently of grouping (the consumer's own `colDef.hide: true`, or + * a prior manual visibility change) is left exactly as-is — grouping by it never makes it + * visible, and un-grouping it never un-hides it either. + * + * @example + * ```html + * + * ``` + */ +@Directive({ + standalone: true, + selector: 'ag-grid-angular[kbqAgGridRowGroup]', + exportAs: 'kbqAgGridRowGroup' +}) +export class KbqAgGridRowGroup { + private readonly grid = inject(AgGridAngular); + private readonly destroyRef = inject(DestroyRef); + private readonly api = signal(null); + + /** Raw row data. Provide this instead of `[rowData]` on the grid element. */ + readonly data = input([], { alias: 'kbqAgGridRowGroupRowData' }); + + /** + * Extracts a stable unique id from a row, used to track selection across group + * collapse/expand cycles. See the class-level `**Recommended**` note. + */ + readonly rowId = input(undefined, { + alias: 'kbqAgGridRowGroupRowId' + }); + + /** + * Column fields to group by, in order (first = outermost group). + * Supports two-way binding: `[(kbqAgGridRowGroupCols)]="fields"`. + */ + readonly groupCols = model([], { alias: 'kbqAgGridRowGroupCols' }); + + /** + * ColDef overrides for the generated group column. Takes precedence over options set via + * {@link kbqAgGridRowGroupColOptionsProvider} when both are provided. + */ + readonly groupColOptions = input( + inject(KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS), + { + alias: 'kbqAgGridRowGroupColOptions' + } + ); + + /** + * Custom component rendered in place of the default group-cell key-only markup — see + * {@link KbqAgGridRowGroupCellContent}. Leaves the chevron icon, expand/collapse toggle + * button, and its keyboard/focus handling untouched; only the label area is replaced. + */ + readonly cellContent = input | undefined>(undefined, { + alias: 'kbqAgGridRowGroupCellContent' + }); + + private readonly _collapsedPaths = signal>(new Set()); + /** + * Set of group paths that are currently collapsed. Read-only — mutate it only through + * `toggleCollapse`/`setExpanded`/`expandAll`/`collapseAll`/`clearGroupColumns`, which + * enforce the "revealing a group starts its children collapsed" invariant (see + * `computeSubGroupPaths`); writing directly here would bypass that and could reveal an + * entire subtree at once instead of one level at a time. + */ + readonly collapsedPaths = this._collapsedPaths.asReadonly(); + + /** The one column (group or data) currently driving native AG Grid sort UI — see + * `ActiveSort`. Structural `equal`: the `sortChanged` subscription below constructs a fresh + * object on every AG event, including ones that don't actually change `colId`/`sort`; + * without this, Angular's default reference equality would treat every such event as a + * change and trigger a redundant rowData rebuild via the "update rowData" effect. */ + private readonly _activeSort = signal(null, { + equal: (a, b) => a?.colId === b?.colId && a?.sort === b?.sort + }); + /** + * Current sort direction applied to the group column — `null` means unsorted (groups appear + * in `kbqAgGridRowGroupRowData` order, as today). Group-header siblings are sorted by their + * key at every nesting level; leaf data rows within a group keep their original order. This + * only ever changes in response to the user clicking the group column's header — the same + * native AG Grid sort UI/icon/keyboard behavior as any other sortable column (see the + * `sortChanged` subscription and `makeGroupColDef`'s `comparator`). Derived from the more + * general `_activeSort`, which also tracks data-column sorting — see `resolveRowGroupDataSort`. + */ + readonly groupColSort = computed(() => { + const active = this._activeSort(); + return active?.colId === GROUP_COL_ID ? active.sort : null; + }); + + /** + * Emits the full list of currently-selected data rows (original `RowData` objects, in + * `kbqAgGridRowGroupRowData` order) whenever selection changes — including rows inside + * collapsed groups. AG Grid's own `(selectionChanged)` output only reports rows currently + * materialized as row nodes, so it misses anything hidden by a collapsed ancestor group; + * use this output instead whenever the complete selection matters. + */ + readonly rowSelectionChanged = output({ alias: 'kbqAgGridRowGroupSelectionChanged' }); + + /** Ids (see `rowId`) of every currently-selected data row — the source of truth for + * selection, independent of which rows AG Grid currently has instantiated/visible. */ + private readonly selectedRowIds = signal>(new Set()); + + /** `RowData` (by reference, from the unfiltered `data()`) → resolved id. Warns (once) on + * missing `rowId` while grouping is active, and (once per id) on duplicate ids. */ + private readonly rowIdMap = computed((): RowIdMap => { + const rowId = this.rowId(); + if (isDevMode() && !rowId && this.groupCols().length > 0 && !this.warnedMissingRowId) { + this.warnedMissingRowId = true; + console.warn( + 'KbqAgGridRowGroup: no [kbqAgGridRowGroupRowId] configured — falling back to ' + + 'array-index row identity. Selection of a partially-selected group will only ' + + 'survive collapsing/expanding it as long as [kbqAgGridRowGroupRowData] keeps ' + + 'the same order and content. Provide [kbqAgGridRowGroupRowId] for reliable behavior.' + ); + } + + const { ids, duplicateIds } = resolveRowIds(this.data(), rowId); + if (isDevMode()) { + for (const id of duplicateIds) { + if (this.warnedDuplicateRowIds.has(id)) continue; + this.warnedDuplicateRowIds.add(id); + console.warn( + `KbqAgGridRowGroup: duplicate row id "${id}" from [kbqAgGridRowGroupRowId] — ` + + 'selection tracking may be incorrect for the affected rows.' + ); + } + } + return ids; + }); + + /** id → ancestor group paths, for every row in the full (unfiltered) dataset — independent + * of `collapsedPaths`, so collapsed descendants still count toward group rollup state. */ + private readonly rowIndex = computed(() => { + const rowIdMap = this.rowIdMap(); + const fields = this.groupCols(); + const index = new Map(); + for (const [row, id] of rowIdMap) { + index.set(id, computeRowAncestorPaths(row, fields)); + } + return index; + }); + + /** Total descendant-row count per group path — structural, doesn't change with selection. */ + private readonly pathTotals = computed(() => { + const totals = new Map(); + for (const ancestorPaths of this.rowIndex().values()) { + for (const path of ancestorPaths) { + totals.set(path, (totals.get(path) ?? 0) + 1); + } + } + return totals; + }); + + /** + * Selection state for each group path — absent means unchecked. Groups are not selectable + * via AG Grid; this is derived purely from `selectedRowIds` + `rowIndex`, independent of + * which rows AG Grid currently has visible/instantiated. + */ + readonly groupSelectionState = computed( + (): ReadonlyMap> => { + const index = this.rowIndex(); + const selected = this.selectedRowIds(); + const totals = this.pathTotals(); + if (selected.size === 0) return new Map(); + + const selectedCounts = new Map(); + for (const id of selected) { + const ancestorPaths = index.get(id); + if (!ancestorPaths) continue; + for (const path of ancestorPaths) { + selectedCounts.set(path, (selectedCounts.get(path) ?? 0) + 1); + } + } + + const result = new Map>(); + for (const [path, total] of totals) { + const count = selectedCounts.get(path) ?? 0; + if (count === 0) continue; + result.set(path, count === total ? 'checked' : 'indeterminate'); + } + return result; + }, + { equal: mapsEqual } + ); + + /** + * Selection state across the full dataset, regardless of grouping/collapse state — drives + * the custom "select all" header checkbox (see `KbqAgGridRowGroupSelectAllHeaderCellRenderer`). + */ + readonly overallSelectionState = computed((): KbqAgGridRowGroupSelectionState => { + const index = this.rowIndex(); + const total = index.size; + if (total === 0) return 'unchecked'; + + let count = 0; + for (const id of this.selectedRowIds()) { + if (index.has(id)) count++; + } + if (count === 0) return 'unchecked'; + return count === total ? 'checked' : 'indeterminate'; + }); + + /** Snapshot of the consumer's own columnDefs, without the synthetic Group column or the + * per-column `comparator` override — kept in sync with `[columnDefs]` via the + * `newColumnsLoaded` subscription below (see `setColumnDefsInternally`). */ + private originalColDefs: (ColDef | ColGroupDef)[] = []; + /** `colId → field` for every data column in `originalColDefs` — see `collectDataColIdToField`. + * Resynced alongside `originalColDefs`. */ + private dataColIdToField: ReadonlyMap = new Map(); + private groupColShown = false; + /** colIds this directive has itself hidden because their field is an active `groupCols` entry + * (see `syncGroupFieldVisibility`) — the source of truth for whether it "owns" a column's + * hidden state. A column already hidden by the consumer's own `colDef.hide: true` (unrelated + * to grouping) is deliberately never added here, so it's never force-shown once it stops being + * a group field. */ + private readonly hiddenGroupFieldColIds = new Set(); + /** Nodes whose next `rowSelected` event was caused by our own `setSelected()` call — skip to prevent re-entrant cascades. */ + private readonly programmaticallySetNodes = new WeakSet(); + /** Deferred collapse: set on gridReady when groupCols is non-empty; cleared once data is available. */ + private needsInitialCollapse = false; + /** Previous groupCols reference — compared by identity to detect structural changes. */ + private prevGroupCols: string[] | null = null; + private warnedMissingRowId = false; + private readonly warnedDuplicateRowIds = new Set(); + /** Last (data, groupCols, collapsedPaths, sort) combination actually applied to the grid — + * lets the "update rowData" effect detect and skip the redundant extra run Angular + * schedules whenever it writes `collapsedPaths` (a signal it also reads) in the same run. */ + private lastRebuildInputs: { + data: RowData[]; + groupCols: string[]; + collapsedPaths: ReadonlySet; + sort: ActiveSort | null; + } | null = null; + + constructor() { + // Reactive: update rowData whenever data, groupCols, collapsedPaths, or groupColSort change + effect( + () => { + const api = this.api(); + if (!api) return; + + const groupCols = this.groupCols(); + + // When the grouping structure changes, collapse all top-level groups. + if (this.prevGroupCols !== null && this.prevGroupCols !== groupCols) { + this._collapsedPaths.set(this.computeTopLevelPaths()); + } + this.prevGroupCols = groupCols; + + // Track collapsedPaths so the effect re-runs after the collapse write. + const collapsedPaths = this.collapsedPaths(); + const data = this.data(); + const sort = this._activeSort(); + + // When groupCols is bound non-empty before data arrives, collapse on first data load. + if (this.needsInitialCollapse && data.length > 0) { + this.needsInitialCollapse = false; + this._collapsedPaths.set(this.computeTopLevelPaths()); + return; + } + + // Writing collapsedPaths above (when the grouping structure changed) makes this + // same effect a dependent of its own write, so Angular schedules one more run of + // it — skip that redundant run instead of rebuilding an identical grid twice. + const last = this.lastRebuildInputs; + if ( + last !== null && + last.data === data && + last.groupCols === groupCols && + last.collapsedPaths === collapsedPaths && + last.sort === sort + ) { + return; + } + this.lastRebuildInputs = { data, groupCols, collapsedPaths, sort }; + + // Restore native AG selection for every visible data row from selectedRowIds — + // the definitive source of truth, independent of collapse state. Read untracked: + // this effect must only re-run on data/groupCols/collapsedPaths/groupColSort + // changes, not on every selection change (which would rebuild rowData on every + // checkbox click). + const selected = untracked(() => this.selectedRowIds()); + + // Mark all current nodes as programmatic so their async deselect events (fired by + // AG Grid when rowData is replaced) are suppressed — skip when nothing is + // selected, since no deselect event could possibly fire in that case. + if (selected.size > 0) { + api.forEachNode((node: IRowNode | undefined>) => { + this.programmaticallySetNodes.add(node); + }); + } + + api.setGridOption('rowData', this.computeGroupedData()); + + if (selected.size > 0) { + this.syncNodeSelection( + api, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (row) => !!row.KbqAgGridRowGroup && selected.has(row.KbqAgGridRowGroup.id), + true + ); + } + }, + { allowSignalWrites: true } + ); + + // Reactive: add/remove the "Group" column when grouping is activated/deactivated, and + // keep each active group field's own data column hidden (see syncGroupFieldVisibility) — + // runs on every groupCols content change, not just the 0↔non-zero boundary above. + effect(() => { + const api = this.api(); + if (!api) return; + const groupCols = this.groupCols(); + const needsGroupCol = groupCols.length > 0; + if (needsGroupCol && !this.groupColShown) { + this.groupColShown = true; + this.setColumnDefsInternally(api, [this.makeGroupColDef(), ...this.makeDataColDefs()]); + } else if (!needsGroupCol && this.groupColShown) { + this.groupColShown = false; + this.setColumnDefsInternally(api, this.originalColDefs); + } + this.syncGroupFieldVisibility(api, groupCols); + }); + + // Redraw group rows whenever selection state changes, so the checkbox cell and the + // `ag-row-selected` rowClassRules class (see makeRowClassRules) both update. redrawRows + // is used rather than refreshCells because rowClassRules are only re-evaluated on a + // row redraw, not a cell-level refresh. + effect(() => { + const api = this.api(); + if (!api) return; + this.groupSelectionState(); + const groupRowNodes: IRowNode[] = []; + api.forEachNode((node: IRowNode) => { + if (isGroupHeaderRow(node.data)) groupRowNodes.push(node); + }); + if (groupRowNodes.length > 0) { + api.redrawRows({ rowNodes: groupRowNodes }); + } + }); + + // Redraw group rows whenever cellContent changes, so already-rendered cells pick up a + // runtime change immediately — KbqAgGridRowGroupCellRenderer reads `cellContent()` live + // off this directive (see its agInit/refresh), rather than from a value snapshotted into + // cellRendererParams once at column-def creation time, precisely so this redraw is enough + // and re-adding the Group column isn't needed. + effect(() => { + const api = this.api(); + if (!api) return; + this.cellContent(); + const groupRowNodes: IRowNode[] = []; + api.forEachNode((node: IRowNode) => { + if (isGroupHeaderRow(node.data)) groupRowNodes.push(node); + }); + if (groupRowNodes.length > 0) { + api.redrawRows({ rowNodes: groupRowNodes }); + } + }); + + // Refresh the custom "select all" header checkbox whenever the overall selection state + // changes (refreshHeader re-invokes the header component's refresh() method). + effect(() => { + const api = this.api(); + if (!api) return; + this.overallSelectionState(); + api.refreshHeader(); + }); + + this.grid.gridReady.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ api }) => { + this.originalColDefs = api.getColumnDefs() ?? []; + this.dataColIdToField = collectDataColIdToField(this.originalColDefs); + + // Group rows are non-selectable — their state lives in groupSelectionState. + // Prefer the non-deprecated rowSelection object form when available; fall back + // to the legacy setGridOption('isRowSelectable') only for string / absent config. + const rowSelection = api.getGridOption('rowSelection'); + if (rowSelection && typeof rowSelection !== 'string') { + // Merge into the existing rowSelection object so user-supplied options are preserved. + // checkboxes:false for group rows suppresses AG Grid's own native selection-column + // checkbox entirely (rather than just disabling it), so only our custom + // group-cell-renderer checkbox is shown in that cell. headerCheckbox:false does the + // same for the header "select all" checkbox — AG's own only knows about currently + // visible rows and disables itself while any group is collapsed; our own custom one + // (see makeSelectionColumnDef) accounts for the full, uncollapsed dataset instead. + const originalCheckboxes = rowSelection.checkboxes; + const mergedSelection = { + ...rowSelection, + isRowSelectable: ((node: IRowNode) => + !isGroupHeaderRow(node.data)) as typeof rowSelection.isRowSelectable, + checkboxes: ((params: CheckboxSelectionCallbackParams) => { + if (isGroupHeaderRow(params.data)) return false; + return typeof originalCheckboxes === 'function' + ? originalCheckboxes(params) + : (originalCheckboxes ?? true); + }) as typeof rowSelection.checkboxes, + hideDisabledCheckboxes: true, + // headerCheckbox only exists on multiRow selection options. + ...(rowSelection.mode === 'multiRow' ? { headerCheckbox: false } : {}) + }; + api.setGridOption('rowSelection', mergedSelection); + } else { + api.setGridOption('isRowSelectable', (node: IRowNode) => !isGroupHeaderRow(node.data)); + } + // Render the custom group checkbox in the shared selection column so it lines up + // with the native row-selection checkboxes instead of sitting inside the group cell. + // Merged onto whatever's already there (the consumer's own [selectionColumnDef] + // input, or KbqAgGridTheme's default width) rather than replacing it outright, so + // neither is silently discarded regardless of which directive's gridReady handler + // runs first — see KbqAgGridTheme.applyDefaultSelectionColumnWidth for the other half. + api.setGridOption('selectionColumnDef', { + ...api.getGridOption('selectionColumnDef'), + ...this.makeSelectionColumnDef() + }); + // Group rows are never truly selected via AG Grid's own API (isRowSelectable is + // false for them), so apply AG's `ag-row-selected` row class ourselves, driven by + // groupSelectionState, to get the same visual highlight as real selected rows. + api.setGridOption('rowClassRules', this.makeRowClassRules()); + queueMicrotask(() => { + if (this.groupCols().length > 0) { + this.needsInitialCollapse = true; + } + this.api.set(api); + }); + }); + + this.grid.rowSelected.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ node }) => { + // Skip events caused by our own programmatic setSelected calls. + if (this.programmaticallySetNodes.has(node)) { + this.programmaticallySetNodes.delete(node); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const row = node.data as Row | undefined; + // Flat mode (no metadata) or group rows — group selection is handled via onGroupCheckboxClick. + if (!row?.KbqAgGridRowGroup || isGroupHeaderRow(row)) return; + + const { id } = row.KbqAgGridRowGroup; + this.selectedRowIds.update((set) => { + const next = new Set(set); + if (node.isSelected()) next.add(id); + else next.delete(id); + return next; + }); + this.emitSelectionChanged(); + }); + + // Reflect whichever single column (group or data) is the current native AG Grid sort + // into _activeSort, which computeGroupedData()/makeRowGroupData() use to reorder either + // group-header siblings or leaf rows within each group (see resolveRowGroupDataSort). + this.grid.sortChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + const api = this.api(); + if (!api) return; + // Not in multi-sort mode (default — no ctrl/shift), AG Grid itself clears every + // other column's sort state before applying a new one, so in practice at most one + // entry here has a non-null sort. sortIndex still picks a deterministic "primary" + // defensively. Multi-column sort chaining is out of scope — only this one primary + // column is ever honored. + const primary = api + .getColumnState() + .filter((s): s is ColumnState & { sort: 'asc' | 'desc' } => s.sort != null) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)) + .at(0); + this._activeSort.set(primary ? { colId: primary.colId, sort: primary.sort } : null); + }); + + // Keep originalColDefs/dataColIdToField in sync with the consumer's own [columnDefs] + // input, not just its value at gridReady — a column added/removed/renamed later is + // picked up going forward (its comparator override, its colId → field resolution). + // AG Grid tags every columnDefs write with who caused it: a direct + // api.setGridOption('columnDefs', ...) call (this directive's own, via + // setColumnDefsInternally) reports source 'api', while the consumer's own [columnDefs] + // input changing is routed by AgGridAngular through gridOptionsChanged instead — + // confirmed by reading AG Grid's _processOnChange / GridOptionsService.updateGridOptions + // (default source 'api') and SyncService.setColumnDefs, which converts a + // 'gridOptionsChanged'-sourced update via _convertColumnEventSourceType before it reaches + // here. Only the latter should trigger a resync — resyncing from our own write would + // replace the real snapshot with our synthetic Group-column/no-op-comparator variant. + this.grid.newColumnsLoaded.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ source }) => { + if (source === 'api') return; + const api = this.api(); + if (!api) return; + + this.originalColDefs = api.getColumnDefs() ?? []; + this.dataColIdToField = collectDataColIdToField(this.originalColDefs); + + // The consumer's own columnDefs write just replaced whatever this directive had + // rendered — if the Group column was showing, put it (and the data-column + // comparator overrides) back on top of the fresh snapshot instead of silently + // losing it. + if (this.groupColShown) { + this.setColumnDefsInternally(api, [this.makeGroupColDef(), ...this.makeDataColDefs()]); + } + + // groupCols itself hasn't changed here, so the "add/remove Group column" effect + // above won't re-run on its own — resync hidden-column state against the fresh + // colDefs explicitly. A true no-op when ungrouped (hiddenGroupFieldColIds is already + // empty by then). + this.syncGroupFieldVisibility(api, this.groupCols()); + }); + } + + /** Returns `true` if the group at `path` is currently collapsed. */ + isCollapsed(path: string): boolean { + return this.collapsedPaths().has(path); + } + + /** Returns the selection state of the group at `path`. */ + getGroupSelectionState(path: string): KbqAgGridRowGroupSelectionState { + return this.groupSelectionState().get(path) ?? 'unchecked'; + } + + /** + * Toggles selection of all data descendants of the group at `path` — across the full + * dataset, not just currently-visible rows. Called by the cell renderer checkbox; + * checked → uncheck all, else → check all. + */ + onGroupCheckboxClick(path: string): void { + const api = this.api(); + if (!api) return; + + const shouldSelect = this.getGroupSelectionState(path) !== 'checked'; + + const affectedIds: string[] = []; + for (const [id, ancestorPaths] of this.rowIndex()) { + if (ancestorPaths.includes(path)) affectedIds.push(id); + } + + this.selectedRowIds.update((set) => { + const next = new Set(set); + for (const id of affectedIds) { + if (shouldSelect) next.add(id); + else next.delete(id); + } + return next; + }); + this.emitSelectionChanged(); + + // Sync AG's live node selection for any of these rows that are currently visible, so + // the checkbox / ag-row-selected class update immediately without waiting for the + // rowData-rebuild effect (which only reruns on data/groupCols/collapsedPaths changes). + this.syncNodeSelection( + api, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (row) => !!row.KbqAgGridRowGroup && row.KbqAgGridRowGroup.ancestors.includes(path), + shouldSelect + ); + } + + /** + * Toggles selection of every data row in the full dataset, regardless of grouping/collapse + * state. Called by the custom "select all" header checkbox; checked → uncheck all, else → + * check all. + */ + onSelectAllCheckboxClick(): void { + const api = this.api(); + if (!api) return; + + const shouldSelect = this.overallSelectionState() !== 'checked'; + this.selectedRowIds.set(shouldSelect ? new Set(this.rowIndex().keys()) : new Set()); + this.emitSelectionChanged(); + + // Sync AG's live node selection for every currently-visible data row. Flat (ungrouped) + // rows carry no KbqAgGridRowGroup metadata at all — only group headers do, and those + // stay unselectable, so matching every non-group row (no further predicate) is correct + // in both modes. + this.syncNodeSelection(api, () => true, shouldSelect); + } + + /** + * Sets the selection state of a single data row identified by `id` (see + * `kbqAgGridRowGroupRowId`) — including a row currently hidden inside a collapsed group, + * which AG Grid's own selection API cannot reach at all since it has no row node for it. + * Group rows are never selectable this way; use `onGroupCheckboxClick` / + * `onSelectAllCheckboxClick` to change a whole group's or the whole dataset's selection. + */ + setRowSelected(id: string | number, selected: boolean): void { + const key = String(id); + this.selectedRowIds.update((set) => { + const next = new Set(set); + if (selected) next.add(key); + else next.delete(key); + return next; + }); + this.emitSelectionChanged(); + + // Sync AG's live node selection if this row happens to be currently visible — if it's + // hidden inside a collapsed group, the "update rowData" effect restores it on expand. + const api = this.api(); + if (!api) return; + this.syncNodeSelection( + api, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (row) => !!row.KbqAgGridRowGroup && row.KbqAgGridRowGroup.id === key, + selected + ); + } + + /** + * Sets the group column's sort direction programmatically — `'asc'`, `'desc'`, or `null` to + * clear it. Goes through AG Grid's own column state API (`applyColumnState`), the same + * mechanism a real header click uses, so the header's sort arrow / `aria-sort` stay in sync + * automatically — there's no need (and no supported way) to set `groupColSort` directly, or + * to reach into `GridApi` yourself, since the group column's `colId` is an internal detail. + */ + setGroupColSort(sort: SortDirection): void { + const api = this.api(); + if (!api) { + if (isDevMode()) { + console.warn( + 'KbqAgGridRowGroup: setGroupColSort() called before the grid is ready — this ' + + 'call is not deferred or replayed, unlike groupCols/collapsedPaths. Call it ' + + 'after gridReady (e.g. from a click handler), not from ngOnInit.' + ); + } + return; + } + api.applyColumnState({ state: [{ colId: GROUP_COL_ID, sort }] }); + } + + /** Toggles the collapsed / expanded state of the group at `path`. */ + toggleCollapse(path: string): void { + const next = new Set(this.collapsedPaths()); + if (next.has(path)) { + // Expanding: reveal children, but start them collapsed + next.delete(path); + for (const subPath of this.computeSubGroupPaths(path)) { + next.add(subPath); + } + } else { + next.add(path); + } + this._collapsedPaths.set(next); + } + + /** + * Sets the expanded / collapsed state of the group at `groupPath` — an ordered array of raw + * field *values* (not field names), outermost group first, e.g. `['Russia', 'Diving']` to + * address the "Diving" sub-group under "Russia". Pass `true` to expand: every ancestor along + * the way is expanded too, so the target group becomes visible without calling this once per + * level in the right order yourself. Pass `false` to collapse just the target group. + * + * @example + * ```ts + * group.setExpanded(['Russia', 'Diving'], true); + * ``` + */ + setExpanded(groupPath: KbqAgGridRowGroupPath, expanded: boolean): void { + if (groupPath.length === 0) return; + + const next = new Set(this.collapsedPaths()); + const segments: string[] = []; + let leafPath = ''; + for (const value of groupPath) { + segments.push(toKey(value)); + leafPath = JSON.stringify(segments); + if (expanded) { + // Expanding: reveal children, but start them collapsed — walking the chain from + // the root down means this also auto-expands every ancestor of the target group. + next.delete(leafPath); + for (const subPath of this.computeSubGroupPaths(leafPath)) { + next.add(subPath); + } + } + } + if (!expanded) next.add(leafPath); + + this._collapsedPaths.set(next); + } + + /** Expands all groups. */ + expandAll(): void { + this._collapsedPaths.set(new Set()); + } + + /** Collapses all top-level groups. */ + collapseAll(): void { + this._collapsedPaths.set(this.computeTopLevelPaths()); + } + + /** Moves the group column at `fromIndex` to `toIndex`. Top-level groups collapse after reorder. */ + moveGroupColumn(fromIndex: number, toIndex: number): void { + const cols = [...this.groupCols()]; + const [item] = cols.splice(fromIndex, 1); + cols.splice(toIndex, 0, item); + this.groupCols.set(cols); + } + + /** Removes all group columns, returning the grid to a flat (ungrouped) view. */ + clearGroupColumns(): void { + this.groupCols.set([]); + this._collapsedPaths.set(new Set()); + } + + private makeGroupColDef(): ColDef { + return { + ...this.groupColOptions(), + // Everything below is fixed and cannot be overridden via `groupColOptions()` / + // `KBQ_AG_GRID_ROW_GROUP_COL_OPTIONS` — it's spread after the line above, so any + // matching key there always loses: + // - filter / suppressHeaderFilterButton: this column shows a *different* + // underlying field's value depending on the row's nesting level (see + // `makeRowGroupData`), so there is no single field a per-value filter UI could + // filter against — enabling it would be broken, not just unstyled. + // - suppressHeaderMenuButton: same reason — there's no column menu (filter / + // columns list) that makes sense for a synthetic, level-dependent column. + // - colId: this exact id is how the directive finds this column elsewhere + // (adding/removing it from columnDefs, reading its sort state below) — letting + // it be overridden would silently break those lookups. + // - sortable / sort / comparator: sorting is handled entirely by this directive + // via `groupColSort`, not by AG Grid's own array sort — `comparator` always + // reporting "equal" makes AG's (stable) native sort a no-op, leaving the + // already-correctly-ordered `rowData` we build ourselves untouched. `sortable`, + // the header click/arrow/aria-sort behavior, and the asc → desc → none cycle are + // otherwise 100% native AG Grid; `sort` just keeps the header arrow in sync with + // `groupColSort` if this column is ever removed and re-added (see the "add/remove + // Group column" effect). Every DATA column gets this same `comparator` no-op + // treatment too, while grouping is active — see `makeDataColDefs`. + filter: false, + suppressHeaderMenuButton: true, + suppressHeaderFilterButton: true, + colId: GROUP_COL_ID, + sortable: true, + sort: this.groupColSort(), + comparator: (): number => 0, + cellRenderer: KbqAgGridRowGroupCellRenderer, + cellRendererParams: { rowGroup: this } satisfies Pick + }; + } + + /** + * Applies the group column's `comparator: () => 0` no-op trick (see `makeGroupColDef`) to + * every DATA column too, recursively including columns nested under `ColGroupDef.children`. + * While grouping is active, AG Grid's own native array sort over a data column would scatter + * our synthetic group-header rows (which carry none of the data fields) throughout + * `rowData`, destroying the grouped tree. AG's own sort UI (header click / arrow / + * aria-sort) stays fully native — only the actual row reordering is suppressed here; the + * `sortChanged` subscription performs the real reorder instead, restricted to leaf rows + * within each deepest group (see `resolveRowGroupDataSort`). `sortable` is left exactly as + * the consumer configured it (or AG's own default) — unaffected by this override. Only + * called while grouping is active; when ungrouped, `originalColDefs` is restored untouched + * and data columns sort with AG's fully native behavior (no group-header rows to scatter). + */ + private makeDataColDefs(): (ColDef | ColGroupDef)[] { + return mapColDefTree(this.originalColDefs, (def) => ({ ...def, comparator: (): number => 0 })); + } + + /** Every columnDefs write this directive itself makes (adding/removing the Group column, + * restoring `originalColDefs`) goes through here rather than a bare `api.setGridOption` + * call — purely for symmetry/documentation with the `newColumnsLoaded` resync subscription + * in the constructor, which relies on this call's resulting event reporting + * `source: 'api'` (AG Grid's default when no explicit source is passed) to recognize it as + * self-caused, as opposed to `source: 'gridOptionsChanged'` for the consumer's own + * `[columnDefs]` input changing. */ + private setColumnDefsInternally(api: GridApi, colDefs: (ColDef | ColGroupDef)[]): void { + api.setGridOption('columnDefs', colDefs); + } + + /** + * Hides the data column for every field currently in `groupCols` — mirrors real AG Grid + * Enterprise row-grouping, where a grouped-by column's own cells are hidden and its value is + * shown only via the synthetic Group column. Restores visibility for any colId this directive + * previously hid once its field stops being a group field. + * + * Ownership: only ever touches a colId this directive owns (`hiddenGroupFieldColIds`). A colId + * already hidden — by the consumer's own `colDef.hide: true`, unrelated to grouping — before it + * became a group field is detected via `api.getColumnState()` and left alone: never taken + * ownership of, so never force-shown later. Uses `api.setColumnsVisible()`, which is decoupled + * from `columnDefs` (goes through `_applyColumnState`, not `createColsFromColDefs` — it never + * fires `newColumnsLoaded`), so this is safe to call from both the "add/remove Group column" + * effect and the `newColumnsLoaded` resync subscription with no risk of feeding back into + * either. + */ + private syncGroupFieldVisibility(api: GridApi, groupCols: readonly string[]): void { + const groupFields = new Set(groupCols); + const shouldHide = new Set(); + for (const [colId, field] of this.dataColIdToField) { + if (groupFields.has(field)) shouldHide.add(colId); + } + + const toShow: string[] = []; + for (const colId of this.hiddenGroupFieldColIds) { + if (shouldHide.has(colId)) continue; + toShow.push(colId); + this.hiddenGroupFieldColIds.delete(colId); + } + + const toHide: string[] = []; + if (shouldHide.size > 0) { + const hiddenElsewhere = new Set( + api + .getColumnState() + .filter((s) => s.hide === true) + .map((s) => s.colId) + ); + for (const colId of shouldHide) { + if (this.hiddenGroupFieldColIds.has(colId) || hiddenElsewhere.has(colId)) continue; + toHide.push(colId); + this.hiddenGroupFieldColIds.add(colId); + } + } + + if (toShow.length > 0) api.setColumnsVisible(toShow, true); + if (toHide.length > 0) api.setColumnsVisible(toHide, false); + } + + private makeSelectionColumnDef(): SelectionColumnDef { + return { + headerComponent: KbqAgGridRowGroupSelectAllHeaderCellRenderer, + headerComponentParams: { + rowGroup: this + } satisfies Pick, + cellRendererSelector: (params: ICellRendererParams): CellRendererSelectorResult | undefined => { + if (!isGroupHeaderRow(params.data)) return undefined; + return { + component: KbqAgGridRowGroupSelectionCellRenderer, + params: { rowGroup: this } satisfies Pick + }; + } + }; + } + + private makeRowClassRules(): RowClassRules { + return { + // untracked: this callback may run synchronously inside the "update rowData" effect + // (AG Grid evaluates rowClassRules while processing setGridOption('rowData', ...)). + // groupSelectionState can't be written anymore (it's a computed()), so this can't + // self-trigger — but reading it untracked still keeps the main effect from gaining + // a spurious dependency on selection state and rebuilding rowData on every click. + 'ag-row-selected': (params) => + untracked(() => { + const row = params.data; + if (!row || !isGroupHeaderRow(row)) return false; + return this.getGroupSelectionState(row.KbqAgGridRowGroup.path) === 'checked'; + }) + }; + } + + /** + * Marks matching, currently-loaded data-row nodes as programmatic and applies `selected` via + * AG's own `setSelected()`. Group rows are always skipped (never independently selectable). + * Skips nodes already at the target value — `setSelected()` is a no-op with no event for + * those, so marking them anyway would leave a stale `programmaticallySetNodes` entry that + * later swallows a real user selection event for that same node. + */ + private syncNodeSelection(api: GridApi, matches: (row: DataRow) => boolean, selected: boolean): void { + api.forEachNode((node: IRowNode) => { + const row = node.data; + if (!row || isGroupHeaderRow(row)) return; + if (!matches(row)) return; + if (node.isSelected() === selected) return; + this.programmaticallySetNodes.add(node); + node.setSelected(selected, false); + }); + } + + /** Resolves `selectedRowIds` back to full `RowData` objects and emits `rowSelectionChanged`. */ + private emitSelectionChanged(): void { + const selected = this.selectedRowIds(); + const rows: RowData[] = []; + for (const [row, id] of this.rowIdMap()) { + if (selected.has(id)) rows.push(row); + } + this.rowSelectionChanged.emit(rows); + } + + /** + * Splits the single active sort (see `_activeSort`) into the group/leaf shape + * `makeRowGroupData` expects. A data-column `colId` not found in `dataColIdToField` (e.g. a + * `sortChanged` event racing a `newColumnsLoaded` resync still in flight) degrades to + * "no sort applied" rather than throwing. + */ + private resolveRowGroupDataSort(): RowGroupDataSort { + const active = this._activeSort(); + if (!active) return { group: null, leaf: null }; + if (active.colId === GROUP_COL_ID) return { group: active.sort, leaf: null }; + const field = this.dataColIdToField.get(active.colId); + return field ? { group: null, leaf: { field, direction: active.sort } } : { group: null, leaf: null }; + } + + private computeGroupedData(): RowData[] { + const fields = this.groupCols(); + const data = this.data(); + if (fields.length === 0) return data; + + return makeRowGroupData(data, fields, this.rowIdMap(), this.collapsedPaths(), this.resolveRowGroupDataSort()); + } + + private computeSubGroupPaths(expandedPath: string): ReadonlySet { + const fields = this.groupCols(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const pathParts = JSON.parse(expandedPath) as string[]; + const subField = fields[pathParts.length]; + if (!subField) return new Set(); + + const filteredData = this.data().filter((row) => { + for (let i = 0; i < pathParts.length; i++) { + const field = fields[i]; + if (!field) return false; + const fieldVal = row[field]; + const key = toKey(fieldVal); + if (key !== pathParts[i]) return false; + } + return true; + }); + + const paths = new Set(); + for (const row of filteredData) { + const val = row[subField]; + paths.add(JSON.stringify([...pathParts, toKey(val)])); + } + return paths; + } + + private computeTopLevelPaths(): ReadonlySet { + const [firstField] = this.groupCols(); + if (!firstField) return new Set(); + const paths = new Set(); + for (const row of this.data()) { + const val = row[firstField]; + paths.add(JSON.stringify([toKey(val)])); + } + return paths; + } +} diff --git a/packages/ag-grid-angular-theme/src/theme.ng.ts b/packages/ag-grid-angular-theme/src/theme.ng.ts index 6b45bed..0f8fe82 100644 --- a/packages/ag-grid-angular-theme/src/theme.ng.ts +++ b/packages/ag-grid-angular-theme/src/theme.ng.ts @@ -3,6 +3,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AgGridAngular } from 'ag-grid-angular'; import { EMPTY, filter, startWith, switchMap } from 'rxjs'; +/** Default width (px) of AG Grid's auto-generated selection checkbox column, matching the + * Koobiq design system's checkbox column sizing (AG's own default is wider). */ +const SELECTION_COLUMN_WIDTH = 36; + /** * Directive that applies the koobiq theme for ag-grid-angular. * @@ -53,6 +57,24 @@ export class KbqAgGridTheme { this.grid.theme = 'legacy'; this.observeColumnsOverflow(); + this.applyDefaultSelectionColumnWidth(); + } + + /** + * Defaults the auto-generated selection checkbox column to `SELECTION_COLUMN_WIDTH`. Merges + * with — never overrides — any `width` already present on `selectionColumnDef`, whether from + * the consumer's own `[selectionColumnDef]` input or another directive on the same grid (e.g. + * `KbqAgGridRowGroup`, which also touches `selectionColumnDef` for its own renderers). Reading + * the current value via `getGridOption` right before writing, rather than assuming an empty + * starting point, keeps this correct regardless of which directive's `gridReady` handler + * happens to run first — see `KbqAgGridRowGroup`'s own `selectionColumnDef` merge for the + * matching half of this. + */ + private applyDefaultSelectionColumnWidth(): void { + this.grid.gridReady.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ api }) => { + const existing = api.getGridOption('selectionColumnDef'); + api.setGridOption('selectionColumnDef', { width: SELECTION_COLUMN_WIDTH, ...existing }); + }); } private observeColumnsOverflow(): void { diff --git a/packages/ag-grid-angular-theme/src/theme.scss b/packages/ag-grid-angular-theme/src/theme.scss index 0916344..d059e36 100644 --- a/packages/ag-grid-angular-theme/src/theme.scss +++ b/packages/ag-grid-angular-theme/src/theme.scss @@ -1420,6 +1420,50 @@ } } +@mixin _ag-row-group-cell-renderer() { + // Based on `KbqAgGridRowGroupCellRenderer` component + .kbq-ag-grid-group-cell-renderer { + display: block; + width: 100%; + height: 100%; + + .kbq-ag-grid-group-cell-renderer__inner { + --kbq-ag-grid-group-cell-renderer-outline-width: 2px; + + display: flex; + align-items: center; + gap: var(--kbq-size-xxs); + width: 100%; + height: 100%; + padding: 0; + border: none; + background: transparent; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: var(--kbq-ag-grid-group-cell-renderer-outline-width) solid var(--kbq-states-line-focus-theme); + outline-offset: calc(var(--kbq-ag-grid-group-cell-renderer-outline-width) * -1); + } + } + + .kbq-ag-grid-group-cell-renderer__icon { + cursor: pointer; + color: var(--kbq-icon-contrast-fade); + + &:hover { + color: var(--kbq-states-icon-contrast-fade-hover); + } + + &:active { + color: var(--kbq-states-icon-contrast-fade-hover); + } + } + } +} + .ag-theme-koobiq { --ag-theme-koobiq-active-color: var(--kbq-background-theme); --ag-theme-koobiq-border: var(--kbq-size-border-width) solid var(--kbq-line-contrast-less); @@ -1452,6 +1496,7 @@ @include _ag-loading-overlay(); @include _ag-row-actions-overlay(); @include _ag-column-menu(); + @include _ag-row-group-cell-renderer(); &.ag-theme-koobiq_disable-cell-focus-styles { @include _ag-disable-cell-focus(); 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 new file mode 100644 index 0000000..d855f8f --- /dev/null +++ b/packages/ag-grid-angular-theme/tests/row-group.ng.spec.ts @@ -0,0 +1,1878 @@ +import { Component, Directive, forwardRef, input, signal, Type, viewChild } from '@angular/core'; +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 { + KbqAgGridRowGroup, + KbqAgGridRowGroupCellContent, + KbqAgGridRowGroupInfo, + KbqAgGridRowGroupRowId +} from '../src/row-group.ng'; + +/** Shared row id extractor bound on every test host below — resolves each row's `id` field. */ +const testRowId: KbqAgGridRowGroupRowId = (row) => String(row.id); + +/** Mirrors the production path encoding (see `toKey`/`makeRowGroupData` in row-group.ng.ts) so + * tests can build an expected `path`/`ancestors` value without hardcoding its internal format. */ +const groupPath = (...values: unknown[]): string => JSON.stringify(values.map((value) => JSON.stringify(value))); + +type MockNode = { + data: Record; + isSelected: () => boolean; + setSelected: jest.Mock; +}; + +const makeMockNode = (data: Record, selected = false): MockNode => { + let _selected = selected; + return { + data, + isSelected: () => _selected, + setSelected: jest.fn((val: boolean) => { + _selected = val; + }) + }; +}; + +const INITIAL_COL_DEFS: ColDef[] = [{ field: 'country' }, { field: 'sport' }]; + +/** Overridable per-test initial columnDefs — reset to `INITIAL_COL_DEFS` in `afterEach` below. + * Lets a test (e.g. one exercising `ColGroupDef` nesting) configure the columns the mock grid + * reports from `getColumnDefs()` without threading a param through every `createApiMock` call. */ +let testColDefs: (ColDef | ColGroupDef)[] = INITIAL_COL_DEFS; + +type MockColumnState = { + colId: string; + sort: 'asc' | 'desc' | null; + sortIndex?: number | null; + hide?: boolean | null; +}; + +const createApiMock = ( + onNodeRemoved: (node: MockNode) => void, + onSortStateApplied: () => void, + onColumnDefsSet: (source: string) => void + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type +) => { + const _nodes: MockNode[] = []; + let _colDefs: (ColDef | ColGroupDef)[] = [...testColDefs]; + let _columnState: MockColumnState[] = []; + const _gridOptions: Record = {}; + + return { + getColumnDefs: jest.fn(() => _colDefs), + getColumnState: jest.fn((): MockColumnState[] => _columnState), + setColumnState: (state: MockColumnState[]): void => { + _columnState = state; + }, + // Simulates real AG Grid: applyColumnState updates the column's sort state and fires + // sortChanged, same as a real header click (see KbqAgGridRowGroup.setGroupColSort). + applyColumnState: jest.fn(({ state }: { state: MockColumnState[] }) => { + for (const entry of state) { + const existing = _columnState.find((s) => s.colId === entry.colId); + if (existing) { + existing.sort = entry.sort; + } else { + _columnState.push(entry); + } + } + onSortStateApplied(); + }), + // Simulates real AG Grid: setColumnsVisible goes through _applyColumnState (the same + // underlying column-state store as applyColumnState/getColumnState above) — never + // through columnDefs, so it never fires newColumnsLoaded. + setColumnsVisible: jest.fn((keys: string[], visible: boolean): void => { + for (const colId of keys) { + const existing = _columnState.find((s) => s.colId === colId); + if (existing) { + existing.hide = !visible; + } else { + _columnState.push({ colId, sort: null, hide: !visible }); + } + } + }), + setGridOption: jest.fn((key: string, value: unknown) => { + _gridOptions[key] = value; + if (key === 'rowData') { + // Simulate real AG Grid: replacing rowData destroys the old nodes, firing an + // async deselect event for any that were selected — before the new nodes exist. + for (const node of _nodes) { + if (node.isSelected()) { + node.setSelected(false); + onNodeRemoved(node); + } + } + _nodes.length = 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (value as Record[]).forEach((row) => { + _nodes.push(makeMockNode(row)); + }); + } else if (key === 'columnDefs') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + _colDefs = value as (ColDef | ColGroupDef)[]; + // Simulates real AG Grid: a direct api.setGridOption('columnDefs', ...) call — + // whether from this directive's own setColumnDefsInternally or any other direct + // API usage — always fires newColumnsLoaded with source: 'api' (AG Grid's + // default when no explicit source is passed). See + // simulateExternalColumnDefsChange for the consumer's own [columnDefs] input + // changing, which is a genuinely different code path in real AG Grid/AgGridAngular + // and reports a different source. + onColumnDefsSet('api'); + } + }), + // Simulates the consumer's own [columnDefs] Angular input changing — AgGridAngular + // routes that through AG Grid's _processOnChange/gridOptionsChanged path, not through + // api.setGridOption, so the resulting newColumnsLoaded event reports source: + // 'gridOptionsChanged' rather than 'api' (confirmed by reading AG Grid's + // GridOptionsService.updateGridOptions and SyncService.setColumnDefs). This is what + // KbqAgGridRowGroup's newColumnsLoaded subscription uses to tell an external columnDefs + // change apart from its own writes. + simulateExternalColumnDefsChange: (colDefs: (ColDef | ColGroupDef)[]): void => { + _colDefs = colDefs; + onColumnDefsSet('gridOptionsChanged'); + }, + getGridOption: jest.fn((key: string): unknown => _gridOptions[key]), + forEachNode: jest.fn((cb: (node: MockNode) => void) => _nodes.forEach(cb)), + refreshCells: jest.fn(), + redrawRows: jest.fn(), + refreshHeader: jest.fn(), + get nodes(): MockNode[] { + return _nodes; + }, + get colDefs(): (ColDef | ColGroupDef)[] { + return _colDefs; + } + }; +}; + +type ApiMock = ReturnType; + +@Directive({ + selector: 'ag-grid-angular', + standalone: true, + providers: [{ provide: AgGridAngular, useExisting: forwardRef(() => TestAgGridAngularStub) }] +}) +class TestAgGridAngularStub { + readonly mock = createApiMock( + (node) => this.emitRowSelected(node), + () => this.emitSortChanged(), + (source) => this.emitNewColumnsLoaded(source) + ); + + get api(): GridApi { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return this.mock as unknown as GridApi; + } + + readonly gridReady = new Subject<{ api: GridApi }>(); + readonly rowSelected = new Subject<{ node: IRowNode }>(); + readonly sortChanged = new Subject(); + readonly newColumnsLoaded = new Subject<{ source: string }>(); + + emitGridReady(): void { + this.gridReady.next({ api: this.api }); + } + + emitNewColumnsLoaded(source: string): void { + this.newColumnsLoaded.next({ source }); + } + + emitRowSelected(node: MockNode): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.rowSelected.next({ node: node as unknown as IRowNode }); + } + + emitSortChanged(): void { + this.sortChanged.next(); + } +} + +@Component({ + selector: 'test-row-group', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowGroup] +}) +class TestRowGroupGrid { + readonly rowData = signal[]>([]); + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowGroup); + protected readonly testRowId = testRowId; +} + +/** Dummy `kbqAgGridRowGroupCellContent` component satisfying the `KbqAgGridRowGroupCellContent` + * contract, used to test that the directive plumbs a custom component through correctly. */ +@Component({ + selector: 'test-group-cell-content', + standalone: true, + template: ` + {{ group().key }} + ` +}) +class TestGroupCellContent implements KbqAgGridRowGroupCellContent { + readonly group = input.required(); +} + +@Component({ + selector: 'test-row-group-cell-content', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowGroup] +}) +class TestRowGroupCellContentHost { + readonly rowData = signal[]>([]); + readonly cellContent = signal | undefined>(undefined); + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowGroup); + protected readonly testRowId = testRowId; +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const setup = async (data: Record[] = []) => { + const { fixture } = await render(TestRowGroupGrid); + fixture.componentInstance.rowData.set(data); + const grid = fixture.componentInstance.grid(); + grid.emitGridReady(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('rowData', expect.any(Array)); + }); + return { fixture, grid, directive: fixture.componentInstance.directive() }; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const getMeta = (row: Record) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + row.KbqAgGridRowGroup as + | { + isGroup: boolean; + path: string; + ancestors: string[]; + key?: string; + level?: number; + count?: number; + } + | undefined; + +const isGroupHeader = (row: Record): boolean => getMeta(row)?.isGroup === true; + +const waitForNodes = async ( + grid: { mock: ApiMock }, + fixture: { detectChanges: () => void }, + predicate: (nodes: MockNode[]) => boolean +): Promise => { + fixture.detectChanges(); + await waitFor(() => { + expect(predicate(grid.mock.nodes)).toBe(true); + }); +}; + +const DATA = [ + { id: '1', country: 'USA', sport: 'Swimming' }, + { id: '2', country: 'USA', sport: 'Athletics' }, + { id: '3', country: 'GBR', sport: 'Cycling' } +]; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const setupWithGroups = async () => { + const result = await setup(DATA); + result.directive.groupCols.set(['country']); + await waitForNodes(result.grid, result.fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + result.directive.expandAll(); + await waitForNodes(result.grid, result.fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + return result; +}; + +describe(KbqAgGridRowGroup.name, () => { + afterEach(() => { + testColDefs = INITIAL_COL_DEFS; + }); + + describe('data grouping', () => { + it('passes raw data as-is when no group columns are active', async () => { + const { grid } = await setup(DATA); + expect(grid.mock.nodes).toHaveLength(DATA.length); + expect(grid.mock.nodes.every((n) => getMeta(n.data) === undefined)).toBe(true); + }); + + it('inserts a group header row per unique value when one group column is active', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headers).toHaveLength(2); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + }); + + it('places data rows under their group with correct ancestors', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows).toHaveLength(2); + }); + + it('hides children of collapsed groups', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + // Groups are collapsed by default as soon as grouping activates — no need to + // explicitly collapse USA. + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + await waitForNodes(grid, fixture, (nodes) => + nodes.every((n) => { + const meta = getMeta(n.data); + return !meta || meta.isGroup || !meta.ancestors.includes(groupPath('USA')); + }) + ); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows).toHaveLength(0); + }); + + it('creates nested group headers when two group columns are active', async () => { + const nestedData = [ + { id: '1', country: 'USA', sport: 'Swimming' }, + { id: '2', country: 'USA', sport: 'Athletics' }, + { id: '3', country: 'GBR', sport: 'Cycling' } + ]; + const { fixture, grid, directive } = await setup(nestedData); + directive.groupCols.set(['country', 'sport']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // Level-0 headers: USA, GBR + const level0Headers = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta?.isGroup && meta.level === 0; + }); + expect(level0Headers).toHaveLength(2); + + // Level-1 headers: Swimming, Athletics (under USA), Cycling (under GBR) + const level1Headers = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta?.isGroup && meta.level === 1; + }); + expect(level1Headers).toHaveLength(3); + }); + + it('does not merge a number value with its string-lookalike into the same group', async () => { + const data = [ + { id: '1', code: 1 }, + { id: '2', code: '1' } + ]; + const { fixture, grid, directive } = await setup(data); + directive.groupCols.set(['code']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headers).toHaveLength(2); + // Distinct internal identity (not merged)... + expect(new Set(headers.map((h) => getMeta(h.data)!.path)).size).toBe(2); + // ...but both still display as the same human-readable label "1". + expect(headers.every((h) => getMeta(h.data)!.key === '1')).toBe(true); + }); + + it('does not merge null and undefined values into the same group', async () => { + const data = [ + { id: '1', code: null }, + { id: '2' } // code is absent entirely -> undefined + ]; + const { fixture, grid, directive } = await setup(data); + directive.groupCols.set(['code']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headers).toHaveLength(2); + expect(new Set(headers.map((h) => getMeta(h.data)!.path)).size).toBe(2); + }); + }); + + describe('initialGroupCols input', () => { + @Component({ + selector: 'test-initial-group-cols', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowGroup] + }) + class TestInitialGroupCols { + readonly data = DATA; + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowGroup); + protected readonly testRowId = testRowId; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const setupInitial = async () => { + const { fixture } = await render(TestInitialGroupCols); + fixture.componentInstance.grid().emitGridReady(); + await waitFor(() => { + expect(fixture.componentInstance.grid().mock.setGridOption).toHaveBeenCalledWith( + 'rowData', + expect.any(Array) + ); + }); + return fixture.componentInstance; + }; + + it('applies groupCols on gridReady', async () => { + const { directive, grid } = await setupInitial(); + expect(directive().groupCols()).toContain('country'); + expect(grid().mock.nodes.some((n) => isGroupHeader(n.data))).toBe(true); + }); + + it('groups are collapsed by default', async () => { + const { directive, grid } = await setupInitial(); + // collapsedPaths must be non-empty and all visible nodes must be group headers + expect(directive().collapsedPaths().size).toBeGreaterThan(0); + expect(grid().mock.nodes.every((n) => isGroupHeader(n.data))).toBe(true); + }); + + it('groups are collapsed even when data arrives after gridReady', async () => { + @Component({ + selector: 'test-initial-group-cols-late-data', + standalone: true, + template: ` + + `, + imports: [TestAgGridAngularStub, KbqAgGridRowGroup] + }) + class TestInitialGroupColsLateData { + readonly rowData = signal[]>([]); + readonly grid = viewChild.required(TestAgGridAngularStub); + readonly directive = viewChild.required(KbqAgGridRowGroup); + protected readonly testRowId = testRowId; + } + + const { fixture } = await render(TestInitialGroupColsLateData); + // Emit gridReady BEFORE data is available — simulates HTTP response arriving later + fixture.componentInstance.grid().emitGridReady(); + + // Flush the queueMicrotask so the gridReady handler runs with empty data. + // This is the critical moment: needsInitialCollapse is set to true, but + // collapseAll() is NOT called (data is empty), so groups must stay collapsed + // when data eventually arrives. + await Promise.resolve(); + + // Data arrives after the microtask (simulates HTTP response) + fixture.componentInstance.rowData.set(DATA); + fixture.detectChanges(); + + // Effect must auto-collapse using the now-available data + await waitFor(() => { + expect(fixture.componentInstance.directive().collapsedPaths().size).toBeGreaterThan(0); + expect(fixture.componentInstance.grid().mock.nodes.every((n) => isGroupHeader(n.data))).toBe(true); + }); + }); + }); + + describe('groupCols management', () => { + it('setting groupCols via model adds a field to grouping', async () => { + const { directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + expect(directive.groupCols()).toContain('country'); + }); + + it('removing a field via groupCols model removes it from grouping', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.groupCols.update((cols) => cols.filter((c) => c !== 'country')); + expect(directive.groupCols()).not.toContain('country'); + }); + + it('moveGroupColumn reorders the fields', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country', 'sport']); + directive.moveGroupColumn(0, 1); + expect(directive.groupCols()).toEqual(['sport', 'country']); + }); + + it('changing groupCols collapses all top-level groups', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + // Expand all groups so the state is non-collapsed before the change + directive.expandAll(); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // Add another group column — all new top-level groups must be collapsed + directive.groupCols.update((cols) => [...cols, 'sport']); + fixture.detectChanges(); + + await waitFor(() => { + expect(directive.collapsedPaths().size).toBeGreaterThan(0); + expect(grid.mock.nodes.every((n) => isGroupHeader(n.data))).toBe(true); + }); + }); + + it('rebuilds the grid exactly once when the grouping structure changes, not twice', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + grid.mock.setGridOption.mockClear(); + + // Structural change: 'country' -> 'sport' produces 3 top-level groups instead of 2. + directive.groupCols.set(['sport']); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.length === 3); + + const rowDataCalls = grid.mock.setGridOption.mock.calls.filter(([key]) => key === 'rowData'); + expect(rowDataCalls).toHaveLength(1); + }); + + it('clearGroupColumns empties groupCols and collapsedPaths', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.toggleCollapse(groupPath('USA')); + directive.clearGroupColumns(); + expect(directive.groupCols()).toHaveLength(0); + expect(directive.collapsedPaths().size).toBe(0); + }); + }); + + describe('column definitions', () => { + it('prepends the group column to columnDefs when groupCols becomes non-empty', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + + expect((grid.mock.colDefs[0] as ColDef).colId).toBe('KbqAgGridRowGroup'); + }); + + it('restores original columnDefs when groupCols becomes empty', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + + directive.clearGroupColumns(); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.colDefs.every((c) => (c as ColDef).colId !== 'KbqAgGridRowGroup')).toBe(true); + }); + + expect(grid.mock.colDefs).toHaveLength(INITIAL_COL_DEFS.length); + }); + + it('the group column is sortable with a no-op comparator — AG never reorders rowData itself', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + + const groupColDef = grid.mock.colDefs[0] as ColDef; + expect(groupColDef.sortable).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const dummyNode = {} as unknown as IRowNode; + expect(groupColDef.comparator?.(null, null, dummyNode, dummyNode, false)).toBe(0); + }); + + it('merges its own selectionColumnDef onto an existing one instead of replacing it', async () => { + const { fixture } = await render(TestRowGroupGrid); + fixture.componentInstance.rowData.set(DATA); + const grid = fixture.componentInstance.grid(); + + // Simulates KbqAgGridTheme's default width (or a consumer's own [selectionColumnDef] + // input) already being set before this directive's gridReady handler runs. + grid.mock.setGridOption('selectionColumnDef', { width: 36 }); + + grid.emitGridReady(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('rowData', expect.any(Array)); + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const selectionColumnDef = grid.mock.getGridOption('selectionColumnDef') as { + width?: number; + headerComponent?: unknown; + }; + expect(selectionColumnDef.width).toBe(36); + expect(selectionColumnDef.headerComponent).toBeDefined(); + }); + }); + + describe('custom cell content', () => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const setupCellContent = async () => { + const { fixture } = await render(TestRowGroupCellContentHost); + fixture.componentInstance.rowData.set(DATA); + const grid = fixture.componentInstance.grid(); + grid.emitGridReady(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('rowData', expect.any(Array)); + }); + return { fixture, grid, directive: fixture.componentInstance.directive() }; + }; + + it('reflects the bound kbqAgGridRowGroupCellContent value on the directive', async () => { + const { fixture, directive } = await setupCellContent(); + expect(directive.cellContent()).toBeUndefined(); + + fixture.componentInstance.cellContent.set(TestGroupCellContent); + fixture.detectChanges(); + + expect(directive.cellContent()).toBe(TestGroupCellContent); + }); + + it('redraws only group header rows when cellContent changes while already grouped', async () => { + const { fixture, grid, directive } = await setupCellContent(); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + grid.mock.redrawRows.mockClear(); + + fixture.componentInstance.cellContent.set(TestGroupCellContent); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.redrawRows).toHaveBeenCalled(); + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const [{ rowNodes }] = grid.mock.redrawRows.mock.calls.at(-1) as [{ rowNodes: MockNode[] }]; + expect(rowNodes.length).toBeGreaterThan(0); + expect(rowNodes.every((node) => isGroupHeader(node.data))).toBe(true); + }); + + it('does not redraw when cellContent changes while ungrouped (no group rows exist)', async () => { + const { fixture, grid } = await setupCellContent(); + grid.mock.redrawRows.mockClear(); + + fixture.componentInstance.cellContent.set(TestGroupCellContent); + fixture.detectChanges(); + // Flush the effect scheduler's microtask so it's had a chance to run before asserting. + await Promise.resolve(); + + expect(grid.mock.redrawRows).not.toHaveBeenCalled(); + }); + }); + + describe('data column visibility while grouped', () => { + it("hides a field's data column when it becomes an active group field", async () => { + const { fixture, directive, grid } = await setup(DATA); + + directive.groupCols.set(['country']); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['country'], false); + }); + }); + + it('shows the data column again once its field is removed from groupCols', async () => { + const { fixture, directive, grid } = await setup(DATA); + directive.groupCols.set(['country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['country'], false); + }); + + directive.groupCols.set([]); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['country'], true); + }); + }); + + it('hides a newly added group field without re-toggling an already-hidden one', async () => { + const { fixture, directive, grid } = await setup(DATA); + directive.groupCols.set(['country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['country'], false); + }); + grid.mock.setColumnsVisible.mockClear(); + + directive.groupCols.set(['country', 'sport']); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['sport'], false); + }); + expect(grid.mock.setColumnsVisible).not.toHaveBeenCalledWith( + expect.arrayContaining(['country']), + expect.anything() + ); + }); + + it('does not force-show a column already hidden by the consumer before it became a group field', async () => { + const { fixture, directive, grid } = await setup(DATA); + grid.mock.setColumnState([{ colId: 'sport', sort: null, hide: true }]); + + directive.groupCols.set(['sport']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + expect(grid.mock.setColumnsVisible).not.toHaveBeenCalledWith(['sport'], false); + + directive.clearGroupColumns(); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.colDefs.every((c) => (c as ColDef).colId !== 'KbqAgGridRowGroup')).toBe(true); + }); + expect(grid.mock.setColumnsVisible).not.toHaveBeenCalledWith(['sport'], true); + }); + + it('moveGroupColumn does not toggle any column visibility', async () => { + const { fixture, directive, grid } = await setup(DATA); + directive.groupCols.set(['country', 'sport']); + fixture.detectChanges(); + await waitFor(() => expect(grid.mock.setColumnsVisible).toHaveBeenCalled()); + grid.mock.setColumnsVisible.mockClear(); + grid.mock.setGridOption.mockClear(); + + directive.moveGroupColumn(0, 1); + fixture.detectChanges(); + + // Reordering still rebuilds rowData (group hierarchy order changed) — wait for that + // to confirm the same effect tick that would also call setColumnsVisible has flushed. + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('rowData', expect.any(Array)); + }); + expect(grid.mock.setColumnsVisible).not.toHaveBeenCalled(); + }); + + it('applies visibility for a group field introduced by an external columnDefs change', async () => { + const { fixture, directive, grid } = await setup(DATA); + directive.groupCols.set(['athlete']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + grid.mock.setColumnsVisible.mockClear(); + + grid.mock.simulateExternalColumnDefsChange([ + { field: 'country' }, + { field: 'sport' }, + { field: 'athlete' } + ]); + + await waitFor(() => { + expect(grid.mock.setColumnsVisible).toHaveBeenCalledWith(['athlete'], false); + }); + }); + }); + + describe('external columnDefs changes', () => { + it('reapplies the Group column on top of an external columnDefs change made while grouping is active', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect((grid.mock.colDefs[0] as ColDef).colId).toBe('KbqAgGridRowGroup'); + }); + + // Simulates the consumer's own [columnDefs] input changing, which would otherwise + // silently drop the synthetic Group column. + grid.mock.simulateExternalColumnDefsChange([ + { field: 'country' }, + { field: 'sport' }, + { field: 'athlete' } + ]); + + await waitFor(() => { + expect((grid.mock.colDefs[0] as ColDef).colId).toBe('KbqAgGridRowGroup'); + }); + const fields = grid.mock.colDefs.slice(1).map((c) => (c as ColDef).field); + expect(fields).toEqual(['country', 'sport', 'athlete']); + // The newly-added data column also gets the no-op comparator, same as the others. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const dummyNode = {} as unknown as IRowNode; + const athleteColDef = grid.mock.colDefs.find((c): c is ColDef => (c as ColDef).field === 'athlete')!; + expect(athleteColDef.comparator?.(null, null, dummyNode, dummyNode, false)).toBe(0); + }); + + it('resolves sorting for a data column added via an external columnDefs change', async () => { + const dataWithExtraField = [ + { id: '1', country: 'USA', sport: 'Swimming', athlete: 'Carol' }, + { id: '2', country: 'USA', sport: 'Athletics', athlete: 'Alice' }, + { id: '3', country: 'GBR', sport: 'Cycling', athlete: 'Dave' } + ]; + const { fixture, grid, directive } = await setup(dataWithExtraField); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // 'athlete' exists on every row already, but isn't a known column yet — sorting by it + // must be a no-op (unresolvable colId) until the consumer's own columnDefs change + // below introduces it. + grid.mock.simulateExternalColumnDefsChange([ + { field: 'country' }, + { field: 'sport' }, + { field: 'athlete' } + ]); + + grid.mock.setColumnState([{ colId: 'athlete', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const usaLeaves = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaLeaves.map((n) => n.data.athlete)).toEqual(['Alice', 'Carol']); + }); + }); + + it("does not mistake its own columnDefs writes for an external change — grouping toggles don't corrupt the snapshot", async () => { + const { fixture, grid, directive } = await setup(DATA); + + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect((grid.mock.colDefs[0] as ColDef).colId).toBe('KbqAgGridRowGroup'); + }); + + directive.clearGroupColumns(); + fixture.detectChanges(); + + // If the newColumnsLoaded resync mistook the directive's own writes above for an + // external change, this would restore the Group-column/no-op-comparator variant + // instead of the consumer's real original columnDefs. + await waitFor(() => { + expect(grid.mock.colDefs).toEqual(INITIAL_COL_DEFS); + }); + }); + }); + + describe('group column sorting', () => { + it('sorting ascending orders top-level groups by key', async () => { + const { grid } = await setupWithGroups(); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['GBR', 'USA']); + }); + }); + + it('sorting descending reverses the order', async () => { + const { grid } = await setupWithGroups(); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: 'desc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + }); + }); + + it('clearing the sort restores the original insertion order', async () => { + const { grid } = await setupWithGroups(); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: 'asc' }]); + grid.emitSortChanged(); + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['GBR', 'USA']); + }); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: null }]); + grid.emitSortChanged(); + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + }); + }); + + it('sorts group-header siblings independently at every nesting level', async () => { + const nestedData = [ + { id: '1', country: 'USA', sport: 'Swimming' }, + { id: '2', country: 'USA', sport: 'Athletics' }, + { id: '3', country: 'GBR', sport: 'Cycling' }, + { id: '4', country: 'GBR', sport: 'Athletics' } + ]; + const { fixture, grid, directive } = await setup(nestedData); + directive.groupCols.set(['country', 'sport']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const sportsUnderGbr = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta?.isGroup && meta.level === 1 && meta.ancestors.includes(groupPath('GBR')); + }); + expect(sportsUnderGbr.map((h) => getMeta(h.data)!.key)).toEqual(['Athletics', 'Cycling']); + }); + }); + + it('sorts numeric-looking keys numerically, not lexicographically', async () => { + const yearData = [ + { id: '1', year: 2 }, + { id: '2', year: 10 }, + { id: '3', year: 9 } + ]; + const { fixture, grid, directive } = await setup(yearData); + directive.groupCols.set(['year']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + grid.mock.setColumnState([{ colId: 'KbqAgGridRowGroup', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['2', '9', '10']); + }); + }); + + it("setGroupColSort applies sort via AG Grid's own column state API, not by writing a signal directly", async () => { + const { grid, directive } = await setupWithGroups(); + + directive.setGroupColSort('asc'); + + expect(grid.mock.applyColumnState).toHaveBeenCalledWith({ + state: [{ colId: 'KbqAgGridRowGroup', sort: 'asc' }] + }); + await waitFor(() => { + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['GBR', 'USA']); + }); + expect(directive.groupColSort()).toBe('asc'); + }); + + it('setGroupColSort(null) clears the sort', async () => { + const { grid, directive } = await setupWithGroups(); + directive.setGroupColSort('asc'); + await waitFor(() => expect(directive.groupColSort()).toBe('asc')); + + directive.setGroupColSort(null); + + await waitFor(() => { + expect(directive.groupColSort()).toBeNull(); + const headers = grid.mock.nodes.filter((n) => isGroupHeader(n.data) && getMeta(n.data)!.level === 0); + expect(headers.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + }); + }); + + it('setGroupColSort warns and is a no-op when called before the grid is ready', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + try { + const { fixture } = await render(TestRowGroupGrid); + const directive = fixture.componentInstance.directive(); + + directive.setGroupColSort('asc'); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('setGroupColSort')); + expect(directive.groupColSort()).toBeNull(); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe('data column sorting', () => { + const ATHLETE_COL_DEFS: ColDef[] = [{ field: 'country' }, { field: 'athlete' }, { field: 'year' }]; + const groupedAthleteData = [ + { id: '1', country: 'USA', athlete: 'Carol', year: 2016 }, + { id: '2', country: 'USA', athlete: 'Alice', year: 2008 }, + { id: '3', country: 'USA', athlete: 'Bob', year: 2012 }, + { id: '4', country: 'GBR', athlete: 'Dave', year: 2004 } + ]; + + it('gives every data column a no-op comparator while grouped, and none when ungrouped', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const dummyNode = {} as unknown as IRowNode; + const dataColDefs = grid.mock.colDefs.filter( + (c) => (c as ColDef).colId !== 'KbqAgGridRowGroup' + ) as ColDef[]; + expect(dataColDefs).toHaveLength(INITIAL_COL_DEFS.length); + for (const colDef of dataColDefs) { + expect(colDef.comparator?.(null, null, dummyNode, dummyNode, false)).toBe(0); + } + + directive.clearGroupColumns(); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.colDefs.every((c) => (c as ColDef).colId !== 'KbqAgGridRowGroup')).toBe(true); + }); + expect(grid.mock.colDefs.every((c) => (c as ColDef).comparator === undefined)).toBe(true); + }); + + it('applies the no-op comparator to a leaf ColDef nested under a ColGroupDef', async () => { + testColDefs = [{ field: 'country' }, { headerName: 'Stats', children: [{ field: 'athlete' }] }]; + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('columnDefs', expect.any(Array)); + }); + + const statsGroup = grid.mock.colDefs.find((c): c is ColGroupDef => 'children' in c)!; + const nestedAthlete = statsGroup.children[0] as ColDef; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const dummyNode = {} as unknown as IRowNode; + expect(nestedAthlete.comparator?.(null, null, dummyNode, dummyNode, false)).toBe(0); + }); + + it('sorting a data column reorders leaf rows within a group without scattering group headers', async () => { + testColDefs = ATHLETE_COL_DEFS; + const { fixture, grid, directive } = await setup(groupedAthleteData); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const headersBefore = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headersBefore.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + + grid.mock.setColumnState([{ colId: 'athlete', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const headersAfter = grid.mock.nodes.filter((n) => isGroupHeader(n.data)); + expect(headersAfter.map((h) => getMeta(h.data)!.key)).toEqual(['USA', 'GBR']); + + const usaLeaves = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaLeaves.map((n) => n.data.athlete)).toEqual(['Alice', 'Bob', 'Carol']); + }); + }); + + it('sorts a numeric leaf field numerically, not lexicographically', async () => { + testColDefs = ATHLETE_COL_DEFS; + const numericLeafData = [ + { id: '1', country: 'USA', athlete: 'A', year: 2 }, + { id: '2', country: 'USA', athlete: 'B', year: 10 }, + { id: '3', country: 'USA', athlete: 'C', year: 9 } + ]; + const { fixture, grid, directive } = await setup(numericLeafData); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + grid.mock.setColumnState([{ colId: 'year', sort: 'asc' }]); + grid.emitSortChanged(); + + await waitFor(() => { + const leaves = grid.mock.nodes.filter((n) => !isGroupHeader(n.data) && getMeta(n.data) !== undefined); + expect(leaves.map((n) => n.data.year)).toEqual([2, 9, 10]); + }); + }); + + it('activating a data column sort clears the active group column sort', async () => { + const { grid, directive } = await setupWithGroups(); + directive.setGroupColSort('asc'); + await waitFor(() => expect(directive.groupColSort()).toBe('asc')); + + // Simulates what AG's own clearSortBarTheseColumns does on a plain header click: the + // previously-active column's sort is cleared, the new one gets it instead. + grid.mock.setColumnState([ + { colId: 'KbqAgGridRowGroup', sort: null }, + { colId: 'country', sort: 'asc' } + ]); + grid.emitSortChanged(); + + await waitFor(() => { + expect(directive.groupColSort()).toBeNull(); + }); + }); + + it('picks the sortIndex-lowest column as primary when multiple entries carry a non-null sort', async () => { + const { directive, grid } = await setupWithGroups(); + + grid.mock.setColumnState([ + { colId: 'country', sort: 'asc', sortIndex: 1 }, + { colId: 'KbqAgGridRowGroup', sort: 'asc', sortIndex: 0 } + ]); + grid.emitSortChanged(); + + await waitFor(() => { + expect(directive.groupColSort()).toBe('asc'); + }); + }); + + it('clearing a data column sort restores the original leaf insertion order', async () => { + testColDefs = ATHLETE_COL_DEFS; + const { fixture, grid, directive } = await setup(groupedAthleteData); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaAthletes = (): unknown[] => + grid.mock.nodes + .filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }) + .map((n) => n.data.athlete); + + grid.mock.setColumnState([{ colId: 'athlete', sort: 'asc' }]); + grid.emitSortChanged(); + await waitFor(() => { + expect(usaAthletes()).toEqual(['Alice', 'Bob', 'Carol']); + }); + + grid.mock.setColumnState([{ colId: 'athlete', sort: null }]); + grid.emitSortChanged(); + await waitFor(() => { + expect(usaAthletes()).toEqual(['Carol', 'Alice', 'Bob']); + }); + }); + }); + + describe('collapse and expand', () => { + it('isCollapsed returns true for a collapsed path', async () => { + const { directive } = await setup(DATA); + directive.toggleCollapse(groupPath('USA')); + expect(directive.isCollapsed(groupPath('USA'))).toBe(true); + }); + + it('isCollapsed returns false for a path not in collapsedPaths', async () => { + const { directive } = await setup(DATA); + directive.toggleCollapse(groupPath('GBR')); + expect(directive.isCollapsed(groupPath('USA'))).toBe(false); + }); + + it('expandAll clears all collapsed paths', async () => { + const { directive } = await setup(DATA); + directive.toggleCollapse(groupPath('USA')); + directive.toggleCollapse(groupPath('GBR')); + directive.expandAll(); + expect(directive.collapsedPaths().size).toBe(0); + }); + + it('collapseAll adds top-level group paths to collapsedPaths', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.collapseAll(); + expect(directive.collapsedPaths().has(groupPath('USA'))).toBe(true); + expect(directive.collapsedPaths().has(groupPath('GBR'))).toBe(true); + }); + + it('toggleCollapse collapses an expanded group', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.toggleCollapse(groupPath('USA')); + expect(directive.isCollapsed(groupPath('USA'))).toBe(true); + }); + + it('toggleCollapse expands a collapsed group', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.toggleCollapse(groupPath('USA')); // start collapsed + directive.toggleCollapse(groupPath('USA')); + expect(directive.isCollapsed(groupPath('USA'))).toBe(false); + }); + + it('toggleCollapse on expand starts immediate sub-groups in collapsed state', async () => { + const nestedData = [ + { id: '1', country: 'USA', sport: 'Swimming' }, + { id: '2', country: 'USA', sport: 'Athletics' } + ]; + const { directive } = await setup(nestedData); + directive.groupCols.set(['country', 'sport']); + directive.toggleCollapse(groupPath('USA')); // start collapsed + directive.toggleCollapse(groupPath('USA')); + // Sub-group paths should now be collapsed + expect(directive.isCollapsed(groupPath('USA', 'Swimming'))).toBe(true); + expect(directive.isCollapsed(groupPath('USA', 'Athletics'))).toBe(true); + }); + + it('setExpanded(groupPath, false) collapses the group', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.setExpanded(['USA'], false); + expect(directive.isCollapsed(groupPath('USA'))).toBe(true); + }); + + it('setExpanded(groupPath, true) expands the group', async () => { + const { directive } = await setup(DATA); + directive.groupCols.set(['country']); + directive.toggleCollapse(groupPath('USA')); + directive.setExpanded(['USA'], true); + expect(directive.isCollapsed(groupPath('USA'))).toBe(false); + }); + + it('setExpanded(groupPath, true) auto-expands every ancestor along a multi-level path', async () => { + const nestedData = [ + { id: '1', country: 'USA', sport: 'Swimming', year: 2000 }, + { id: '2', country: 'USA', sport: 'Swimming', year: 2004 }, + { id: '3', country: 'USA', sport: 'Athletics', year: 2000 } + ]; + const { directive } = await setup(nestedData); + directive.groupCols.set(['country', 'sport', 'year']); + directive.collapseAll(); + + // One call addresses the 3rd-level group directly, without expanding each + // ancestor level one at a time in order. + directive.setExpanded(['USA', 'Swimming', 2000], true); + + expect(directive.isCollapsed(groupPath('USA'))).toBe(false); + expect(directive.isCollapsed(groupPath('USA', 'Swimming'))).toBe(false); + // `year` is a number field — its path segment is type-prefixed (see `toKey`) so it + // can never collide with a same-looking string value from another field. + expect(directive.isCollapsed(groupPath('USA', 'Swimming', 2000))).toBe(false); + + // Siblings at every level start collapsed, exactly like expanding one chevron + // at a time would produce. + expect(directive.isCollapsed(groupPath('USA', 'Athletics'))).toBe(true); + expect(directive.isCollapsed(groupPath('USA', 'Swimming', 2004))).toBe(true); + }); + + it('setExpanded(groupPath, false) only collapses the target group, not its ancestors', async () => { + const nestedData = [ + { id: '1', country: 'USA', sport: 'Swimming', year: 2000 }, + { id: '2', country: 'USA', sport: 'Swimming', year: 2004 } + ]; + const { directive } = await setup(nestedData); + directive.groupCols.set(['country', 'sport', 'year']); + directive.setExpanded(['USA', 'Swimming', 2000], true); + + directive.setExpanded(['USA', 'Swimming'], false); + + expect(directive.isCollapsed(groupPath('USA', 'Swimming'))).toBe(true); + expect(directive.isCollapsed(groupPath('USA'))).toBe(false); + }); + + it('does not scan grid nodes on a rebuild when nothing is selected', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + grid.mock.forEachNode.mockClear(); + grid.mock.setGridOption.mockClear(); + + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + await waitFor(() => { + expect(grid.mock.setGridOption).toHaveBeenCalledWith('rowData', expect.any(Array)); + }); + + expect(grid.mock.forEachNode).not.toHaveBeenCalled(); + }); + }); + + describe('groupSelectsChildren', () => { + it('clicking group checkbox selects all descendant data rows', async () => { + const { grid, directive } = await setupWithGroups(); + + directive.onGroupCheckboxClick(groupPath('USA')); + + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaChildren.length).toBeGreaterThan(0); + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenCalledWith(true, false); + } + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + }); + + it('clicking group checkbox again deselects all descendants', async () => { + const { grid, directive } = await setupWithGroups(); + + directive.onGroupCheckboxClick(groupPath('USA')); // select + directive.onGroupCheckboxClick(groupPath('USA')); // deselect + + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenLastCalledWith(false, false); + } + expect(directive.groupSelectionState().get(groupPath('USA'))).toBeUndefined(); + }); + + it('does not swallow a real deselect on a child that was already selected before the group checkbox click', async () => { + const { grid, directive } = await setupWithGroups(); + + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaChildren.length).toBeGreaterThanOrEqual(2); + const [alreadySelected, ...rest] = usaChildren; + + // Select one child individually first (a real user selection). + alreadySelected.setSelected(true); + grid.emitRowSelected(alreadySelected); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + + // Click the group checkbox — this re-selects every child, including the one already + // selected above. AG Grid's real setSelected() is a no-op (no event) for a node + // already at the target value, so the already-selected child must NOT be marked + // "programmatic" here, or a later real deselect on it would be silently swallowed. + directive.onGroupCheckboxClick(groupPath('USA')); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + + // The user now manually deselects the child that was already selected before the + // group click (never touched by setSelected during that click). + alreadySelected.setSelected(false); + grid.emitRowSelected(alreadySelected); + + // This must be treated as a real, un-swallowed deselect. + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + expect(rest.every((child) => child.isSelected())).toBe(true); + }); + + it('selecting a data row does not propagate to sibling data rows', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + const [firstDataRow, ...siblingRows] = usaDataRows; + firstDataRow.setSelected(true); + grid.emitRowSelected(firstDataRow); + + for (const sibling of siblingRows) { + expect(sibling.isSelected()).toBe(false); + } + }); + + it('selecting the only child of a group marks the group as checked', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['GBR'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const gbrChild = grid.mock.nodes.find((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('GBR')); + })!; + gbrChild.setSelected(true); + grid.emitRowSelected(gbrChild); + + expect(directive.groupSelectionState().get(groupPath('GBR'))).toBe('checked'); + }); + + it('selecting one of multiple children marks the group as indeterminate', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const [firstDataRow] = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + firstDataRow.setSelected(true); + grid.emitRowSelected(firstDataRow); + + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + }); + + it('selecting all children one by one eventually marks the group as checked', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows.length).toBeGreaterThan(1); + + // Select all but the last — group must be indeterminate + for (let i = 0; i < usaDataRows.length - 1; i++) { + usaDataRows[i].setSelected(true); + grid.emitRowSelected(usaDataRows[i]); + } + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + + // Select the last — group must become checked + usaDataRows.at(-1)!.setSelected(true); + grid.emitRowSelected(usaDataRows.at(-1)!); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + }); + + it('deselecting one child from a fully-selected group makes it indeterminate', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + for (const row of usaDataRows) { + row.setSelected(true); + grid.emitRowSelected(row); + } + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + + usaDataRows[0].setSelected(false); + grid.emitRowSelected(usaDataRows[0]); + + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + }); + + it('deselecting one child keeps all sibling data rows selected', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows.length).toBeGreaterThanOrEqual(2); + + for (const row of usaDataRows) { + row.setSelected(true); + grid.emitRowSelected(row); + } + + // Deselect the first child only + usaDataRows[0].setSelected(false); + grid.emitRowSelected(usaDataRows[0]); + + // Siblings must remain selected — handler must NOT touch them + for (const sibling of usaDataRows.slice(1)) { + expect(sibling.isSelected()).toBe(true); + } + }); + + it('bottom-up recalculation works recursively through N levels', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country', 'sport']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // GBR::Cycling has exactly one data row — selecting it propagates all the way up + const gbrCyclingDataRow = grid.mock.nodes.find((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && n.data.country === 'GBR'; + })!; + gbrCyclingDataRow.setSelected(true); + grid.emitRowSelected(gbrCyclingDataRow); + + expect(directive.groupSelectionState().get(groupPath('GBR', 'Cycling'))).toBe('checked'); + expect(directive.groupSelectionState().get(groupPath('GBR'))).toBe('checked'); + }); + + it('deselecting one child propagates up through all ancestor levels', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country', 'sport']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // Select all data rows one by one to build up group states bottom-up + const dataRows = grid.mock.nodes.filter((n) => !isGroupHeader(n.data)); + for (const node of dataRows) { + node.setSelected(true); + grid.emitRowSelected(node); + } + expect(directive.groupSelectionState().get(groupPath('GBR'))).toBe('checked'); + + // GBR::Cycling has exactly one data row — deselecting it unchecks both the + // GBR::Cycling sub-group and the GBR top-level group + const gbrCyclingDataRow = grid.mock.nodes.find((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && n.data.country === 'GBR'; + })!; + gbrCyclingDataRow.setSelected(false); + grid.emitRowSelected(gbrCyclingDataRow); + + expect(directive.groupSelectionState().get(groupPath('GBR', 'Cycling'))).toBeUndefined(); + expect(directive.groupSelectionState().get(groupPath('GBR'))).toBeUndefined(); + // USA is unrelated — must remain checked + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + }); + + it('each descendant is selected exactly once when group checkbox is clicked', async () => { + const { grid, directive } = await setupWithGroups(); + + directive.onGroupCheckboxClick(groupPath('USA')); + + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenCalledTimes(1); + } + }); + }); + + describe('selection restore on rowData change', () => { + it('preserves group selection state and re-selects children after rowData is replaced on expand', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + + // Select USA's whole subtree via its group checkbox (groups are non-selectable via AG Grid) + directive.onGroupCheckboxClick(groupPath('USA')); + + // Expand USA — triggers a rowData refresh via the collapsedPaths effect + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + + await waitFor(() => { + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaChildren.length).toBeGreaterThan(0); + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenCalledWith(true, false); + } + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('checked'); + }); + }); + + it('auto-selects children of a restored group after expand', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); // let groupCols-change reset run (collapsedPaths → empty) + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.collapseAll(); // collapse after the reset has settled + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.every((n) => isGroupHeader(n.data))); + + // Select USA's whole subtree while it is collapsed + directive.onGroupCheckboxClick(groupPath('USA')); + + // Expand USA — triggers rowData refresh + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + + await waitFor(() => { + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaChildren.length).toBeGreaterThan(0); + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenCalledWith(true, false); + } + }); + }); + + it('adding a group column while a flat row is selected does not throw', async () => { + // Regression: row.KbqAgGridRowGroup is undefined for flat rows; accessing .isGroup + // without optional chaining threw TypeError → Angular re-scheduled the effect → infinite loop. + const { fixture, grid, directive } = await setup(DATA); + + // Verify the current nodes are truly flat (no KbqAgGridRowGroup metadata) + const [flatNode] = grid.mock.nodes; + expect(getMeta(flatNode.data)).toBeUndefined(); + flatNode.setSelected(true); + + // Must not throw; effect must complete and populate grouped rows + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); + + await waitFor(() => { + expect(grid.mock.nodes.some((n) => isGroupHeader(n.data))).toBe(true); + }); + }); + + it('rowSelected events fired after programmatic restoration are suppressed', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.update((cols) => [...cols, 'country']); + fixture.detectChanges(); // let groupCols-change reset run (collapsedPaths → empty) + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.collapseAll(); // collapse after the reset has settled + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.every((n) => isGroupHeader(n.data))); + + // Select USA's whole subtree while collapsed + directive.onGroupCheckboxClick(groupPath('USA')); + + // Expand: effect restores selection and adds restored data nodes to programmaticallySetNodes + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + + // Wait for restored children to appear and be selected + await waitFor(() => { + expect(grid.mock.nodes.some((n) => !isGroupHeader(n.data) && n.isSelected())).toBe(true); + }); + + const usaChildren = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + + // Simulate the async rowSelected event that AG Grid fires after a programmatic setSelected. + // The handler must recognize this node as programmatically set and suppress it, + // so groupSelectionState is not re-evaluated and setSelected is not called a second time. + grid.emitRowSelected(usaChildren[0]); + + // Each child must have been set exactly once (during restoration) + for (const child of usaChildren) { + expect(child.setSelected).toHaveBeenCalledTimes(1); + } + }); + + it('preserves a partial (indeterminate) selection across collapsing and re-expanding the group', async () => { + const { fixture, grid, directive } = await setup(DATA); + directive.groupCols.set(['country']); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => isGroupHeader(n.data))); + directive.expandAll(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + // Select only one of USA's two children — group ends up indeterminate. + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows.length).toBeGreaterThanOrEqual(2); + usaDataRows[0].setSelected(true); + grid.emitRowSelected(usaDataRows[0]); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + + // Collapse USA — this used to silently discard the partial selection. + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => + nodes.every((n) => { + const meta = getMeta(n.data); + return !meta || meta.isGroup || !meta.ancestors.includes(groupPath('USA')); + }) + ); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + + // Re-expand — the same single child must still be selected, group still indeterminate. + directive.toggleCollapse(groupPath('USA')); + fixture.detectChanges(); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const restoredUsaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + const selectedRows = restoredUsaDataRows.filter((n) => n.isSelected()); + expect(selectedRows).toHaveLength(1); + expect(selectedRows[0].data.id).toBe(usaDataRows[0].data.id); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + }); + }); + + describe('select-all header checkbox', () => { + it('overallSelectionState is unchecked when nothing is selected', async () => { + const { directive } = await setup(DATA); + expect(directive.overallSelectionState()).toBe('unchecked'); + }); + + it('onSelectAllCheckboxClick selects every row across the whole dataset, even collapsed ones', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + // Collapse USA back down so its children are not currently loaded into the row model. + directive.toggleCollapse(groupPath('USA')); + + directive.onSelectAllCheckboxClick(); + + expect(directive.overallSelectionState()).toBe('checked'); + + // Re-expand — the previously-collapsed children must come back selected too. + directive.toggleCollapse(groupPath('USA')); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + const usaDataRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaDataRows.length).toBeGreaterThan(0); + for (const row of usaDataRows) { + expect(row.isSelected()).toBe(true); + } + }); + + it('onSelectAllCheckboxClick called again deselects every row', async () => { + const { grid, directive } = await setupWithGroups(); + + directive.onSelectAllCheckboxClick(); + expect(directive.overallSelectionState()).toBe('checked'); + + directive.onSelectAllCheckboxClick(); + expect(directive.overallSelectionState()).toBe('unchecked'); + expect(grid.mock.nodes.every((n) => !n.isSelected())).toBe(true); + }); + + it('selecting one row marks overallSelectionState as indeterminate', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.setExpanded(['USA'], true); + await waitForNodes(grid, fixture, (nodes) => nodes.some((n) => !isGroupHeader(n.data))); + + const [firstDataRow] = grid.mock.nodes.filter((n) => !isGroupHeader(n.data)); + firstDataRow.setSelected(true); + grid.emitRowSelected(firstDataRow); + + expect(directive.overallSelectionState()).toBe('indeterminate'); + }); + }); + + describe('rowId validation', () => { + it('warns when grouping is active without a configured rowId', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + try { + const { directive } = await setup([ + { country: 'USA', sport: 'Swimming' }, + { country: 'GBR', sport: 'Cycling' } + ]); + directive.groupCols.set(['country']); + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('kbqAgGridRowGroupRowId')); + }); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe('rowSelectionChanged output', () => { + it('emits every selected row across the whole dataset when a group checkbox is clicked, even collapsed ones', async () => { + const { directive } = await setupWithGroups(); + // Re-collapse USA so its children are not currently loaded into the row model — + // AG Grid's own (selectionChanged) event could never report these. + directive.toggleCollapse(groupPath('USA')); + + const emitSpy = jest.spyOn(directive.rowSelectionChanged, 'emit'); + directive.onGroupCheckboxClick(groupPath('USA')); + + expect(emitSpy).toHaveBeenCalledTimes(1); + const [[emitted]] = emitSpy.mock.calls; + expect(emitted.map((row) => String(row.id)).sort()).toEqual(['1', '2']); + }); + + it('reflects deselecting one child against the full selected set, not just visible rows', async () => { + const { grid, directive } = await setupWithGroups(); + const usaRows = grid.mock.nodes.filter((n) => { + const meta = getMeta(n.data); + return meta && !meta.isGroup && meta.ancestors.includes(groupPath('USA')); + }); + expect(usaRows.length).toBeGreaterThanOrEqual(2); + for (const row of usaRows) { + row.setSelected(true); + grid.emitRowSelected(row); + } + + const emitSpy = jest.spyOn(directive.rowSelectionChanged, 'emit'); + const [firstUsaRow] = usaRows; + firstUsaRow.setSelected(false); + grid.emitRowSelected(firstUsaRow); + + expect(emitSpy).toHaveBeenCalledTimes(1); + const [[emitted]] = emitSpy.mock.calls; + expect(emitted).toHaveLength(usaRows.length - 1); + expect(emitted.some((row) => row.id === firstUsaRow.data.id)).toBe(false); + }); + }); + + describe('setRowSelected', () => { + it('selects a currently visible row and rolls up into groupSelectionState', async () => { + const { grid, directive } = await setupWithGroups(); + + directive.setRowSelected('1', true); + + const row1 = grid.mock.nodes.find((n) => n.data.id === '1')!; + expect(row1.isSelected()).toBe(true); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + }); + + it('selects a row hidden inside a collapsed group — rollup updates with no AG node for it', async () => { + const { directive } = await setupWithGroups(); + directive.toggleCollapse(groupPath('USA')); // re-collapse so USA's children aren't loaded + + directive.setRowSelected('1', true); + + // No AG node exists for the hidden row, but the group's rollup state is still + // correct — it's derived entirely from selectedRowIds, independent of visibility. + expect(directive.groupSelectionState().get(groupPath('USA'))).toBe('indeterminate'); + }); + + it('restores a selection made while hidden once the group is expanded', async () => { + const { grid, directive, fixture } = await setupWithGroups(); + directive.toggleCollapse(groupPath('USA')); + directive.setRowSelected('1', true); + + directive.toggleCollapse(groupPath('USA')); // expand again + 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('setRowSelected(id, false) deselects a row', async () => { + const { grid, directive } = await setupWithGroups(); + directive.setRowSelected('1', true); + + directive.setRowSelected('1', false); + + const row1 = grid.mock.nodes.find((n) => n.data.id === '1')!; + expect(row1.isSelected()).toBe(false); + expect(directive.groupSelectionState().get(groupPath('USA'))).toBeUndefined(); + }); + + it('emits rowSelectionChanged with the full selected row set', async () => { + const { directive } = await setupWithGroups(); + + const emitSpy = jest.spyOn(directive.rowSelectionChanged, 'emit'); + directive.setRowSelected('1', true); + + expect(emitSpy).toHaveBeenCalledTimes(1); + const [[emitted]] = emitSpy.mock.calls; + expect(emitted.map((row) => String(row.id))).toEqual(['1']); + }); + }); +}); diff --git a/packages/ag-grid-angular-theme/tests/theme.ng.spec.ts b/packages/ag-grid-angular-theme/tests/theme.ng.spec.ts index 2350ed8..41fd211 100644 --- a/packages/ag-grid-angular-theme/tests/theme.ng.spec.ts +++ b/packages/ag-grid-angular-theme/tests/theme.ng.spec.ts @@ -1,7 +1,7 @@ import { Component, viewChild } from '@angular/core'; -import { render } from '@testing-library/angular'; +import { render, waitFor } from '@testing-library/angular'; import { AgGridAngular, AgGridModule } from 'ag-grid-angular'; -import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; +import { AllCommunityModule, ModuleRegistry, SelectionColumnDef } from 'ag-grid-community'; import { KbqAgGridTheme } from '../src/theme.ng'; ModuleRegistry.registerModules([AllCommunityModule]); @@ -28,6 +28,19 @@ class TestGrid { }) class TestGridFocusDisabled {} +@Component({ + selector: 'test-grid-custom-selection-width', + standalone: true, + template: ` + + `, + imports: [AgGridModule, KbqAgGridTheme] +}) +class TestGridCustomSelectionWidth { + readonly grid = viewChild.required(AgGridAngular); + protected readonly selectionColumnDef: SelectionColumnDef = { width: 80 }; +} + describe(KbqAgGridTheme.name, () => { it('should apply ag-theme-koobiq host class', async () => { const { container } = await render(TestGrid); @@ -52,4 +65,24 @@ describe(KbqAgGridTheme.name, () => { expect(container.querySelector('ag-grid-angular')).toHaveClass('ag-theme-koobiq_disable-cell-focus-styles'); }); + + it('defaults the selection checkbox column width to 36px', async () => { + const { fixture } = await render(TestGrid); + + await waitFor(() => { + expect(fixture.componentInstance.grid().api.getGridOption('selectionColumnDef')).toEqual( + expect.objectContaining({ width: 36 }) + ); + }); + }); + + it("does not override a consumer's own selectionColumnDef width", async () => { + const { fixture } = await render(TestGridCustomSelectionWidth); + + await waitFor(() => { + expect(fixture.componentInstance.grid().api.getGridOption('selectionColumnDef')).toEqual( + expect.objectContaining({ width: 80 }) + ); + }); + }); });