From 10b3b357fdbec0a779fc068ff431bbe72227eeb7 Mon Sep 17 00:00:00 2001 From: Alyar Date: Fri, 13 Nov 2026 10:29:17 +0000 Subject: [PATCH] DataGrid: Fix selected and focused rows not highlighted while async templates render --- .../__mock__/helpers/pending_template.ts | 64 ++++++ .../focus.async_templates.integration.test.ts | 70 ++++++ .../grids/grid_core/focus/m_focus.ts | 34 +++ ...ection.async_templates.integration.test.ts | 217 ++++++++++++++++++ ...ow_checkboxes_on_click.integration.test.ts | 29 +++ .../grids/grid_core/selection/m_selection.ts | 76 +++++- .../grids/grid_core/selection/utils.ts | 12 + 7 files changed, 491 insertions(+), 11 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/helpers/pending_template.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/focus/__tests__/focus.async_templates.integration.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.async_templates.integration.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.show_checkboxes_on_click.integration.test.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/helpers/pending_template.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/helpers/pending_template.ts new file mode 100644 index 000000000000..87bfef5bb779 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/helpers/pending_template.ts @@ -0,0 +1,64 @@ +import { jest } from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridModel } from '@ts/grids/data_grid/__tests__/__mock__/model/data_grid'; + +import type { DataGridInstance } from './utils'; +import { createDataGrid } from './utils'; + +interface TemplateArgs { + model: { value: unknown }; + container: HTMLElement; + onRendered?: () => void; +} + +interface PendingTemplate { + template: { render: (args: TemplateArgs) => void }; + renderPendingTemplates: () => void; +} + +export const ROWS = [{ id: 1 }, { id: 2 }, { id: 3 }]; + +export const createPendingTemplate = (): PendingTemplate => { + const pendingTemplates: (() => void)[] = []; + + return { + template: { + render({ model, container, onRendered }: TemplateArgs): void { + pendingTemplates.push(() => { + container.append(String(model.value)); + onRendered?.(); + }); + }, + }, + renderPendingTemplates: (): void => { + while (pendingTemplates.length) { + pendingTemplates.shift()?.(); + jest.runAllTimers(); + } + }, + }; +}; + +export const createGridWithPendingTemplates = async ( + options: DataGridProperties, +): Promise<{ + $container: dxElementWrapper; + component: DataGridModel; + instance: DataGridInstance; + renderPendingTemplates: () => void; +}> => { + const { template, renderPendingTemplates } = createPendingTemplate(); + + const grid = await createDataGrid({ + dataSource: ROWS, + columns: [{ dataField: 'id', cellTemplate: 'cellTemplate' }], + templatesRenderAsynchronously: true, + integrationOptions: { + templates: { cellTemplate: template }, + }, + ...options, + } as DataGridProperties); + + return { ...grid, renderPendingTemplates }; +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/focus/__tests__/focus.async_templates.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/focus/__tests__/focus.async_templates.integration.test.ts new file mode 100644 index 000000000000..dc1b35583aa5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/focus/__tests__/focus.async_templates.integration.test.ts @@ -0,0 +1,70 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; + +import { createGridWithPendingTemplates } from '../../__tests__/__mock__/helpers/pending_template'; +import { afterTest, beforeTest } from '../../__tests__/__mock__/helpers/utils'; + +const getRowKeys = ($container: dxElementWrapper, selector: string): number[] => { + const container = $container.get(0) as HTMLElement; + + return Array.from( + container.querySelectorAll(`.dx-datagrid-rowsview ${selector}`), + (element) => Number(element.closest('tr')?.textContent), + ); +}; + +describe('Focused row while new rows wait for async templates', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('should highlight the row focused with focusedRowKey', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + focusedRowEnabled: true, + }); + + instance.option('focusedRowKey', 2); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(getRowKeys($container, 'tr.dx-row-focused')).toEqual([2]); + }); + + it('should highlight the focused row when a detail row is expanded above it', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + focusedRowEnabled: true, + masterDetail: { + enabled: true, + template: (container: HTMLElement): void => { + container.append('detail'); + }, + }, + }); + + instance.option('focusedRowKey', 2); + jest.runAllTimers(); + const expanding = instance.expandRow(1); + jest.runAllTimers(); + renderPendingTemplates(); + await expanding; + + expect(getRowKeys($container, 'tr.dx-row-focused')).toEqual([2]); + }); + + it('should not highlight the repainted row when the focused row key is reset', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + focusedRowEnabled: true, + focusedRowKey: 2, + }); + + renderPendingTemplates(); + instance.repaintRows([1]); + jest.runAllTimers(); + instance.option('focusedRowKey', null); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(getRowKeys($container, 'tr.dx-row-focused')).toEqual([]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts b/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts index 42b7c778e3e6..0409074664d8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts @@ -947,6 +947,40 @@ const rowsView = (Base: ModuleType) => class RowsViewFocusController e } } + protected _renderCore(change) { + const deferred = super._renderCore(change); + const isWaitingForTemplates = this.isWaitingForAsyncTemplates(); + + return deferred.done(() => { + if (isWaitingForTemplates) { + this.syncFocusedRow(); + } + }); + } + + private syncFocusedRow(): void { + if (!this.option('focusedRowEnabled')) { + return; + } + + const focusedRowKey = this.option('focusedRowKey'); + const focusedRowIndex = isDefined(focusedRowKey) + ? this._dataController.getRowIndexByKey(focusedRowKey) + : -1; + const isHighlightOutdated = this._getRowElements() + .toArray() + .some((rowElement, rowIndex) => { + const isHighlighted = rowElement.classList.contains(ROW_FOCUSED_CLASS); + const isFocused = rowIndex === focusedRowIndex; + + return isHighlighted !== isFocused; + }); + + if (isHighlightOutdated) { + this._focusController.updateFocusedRow({ focusedRowKey, preventScroll: true }); + } + } + private updateFocusElementTabIndex($cellElements, preventScroll) { if (this.option('focusedRowEnabled')) { this._setFocusedRowElementTabIndex(preventScroll); diff --git a/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.async_templates.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.async_templates.integration.test.ts new file mode 100644 index 000000000000..1009dac03848 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.async_templates.integration.test.ts @@ -0,0 +1,217 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; + +import { createGridWithPendingTemplates, ROWS } from '../../__tests__/__mock__/helpers/pending_template'; +import { + afterTest, + beforeTest, + createDataGrid, + flushAsync, +} from '../../__tests__/__mock__/helpers/utils'; +import { COMMAND_SELECT_CLASS } from '../const'; + +const RESTORED_STATE = { selectedRowKeys: [1] }; + +// legacyMode isn't in the public type +const LEGACY_INFINITE_SCROLLING_OPTIONS = { + scrolling: { mode: 'infinite', legacyMode: true }, +} as DataGridProperties; + +const getRowKeys = ($container: dxElementWrapper, selector: string): number[] => { + const container = $container.get(0) as HTMLElement; + + return Array.from( + container.querySelectorAll(`.dx-datagrid-rowsview ${selector}`), + (element) => Number( + element.closest('tr')?.querySelector(`td:not(.${COMMAND_SELECT_CLASS})`)?.textContent, + ), + ); +}; + +describe('Selection while new rows wait for async templates', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('should highlight the rows selected by the restored state', async () => { + const { $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + selection: { mode: 'multiple' }, + stateStoring: { + enabled: true, + type: 'custom', + customLoad: () => Promise.resolve(RESTORED_STATE), + }, + }); + + await flushAsync(); + renderPendingTemplates(); + + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([1]); + expect(getRowKeys($container, '.dx-checkbox-checked')).toEqual([1]); + }); + + it('should highlight the rows selected by the restored state when a column renders asynchronously', async () => { + const { $container } = await createDataGrid({ + dataSource: ROWS, + columns: [{ dataField: 'id', renderAsync: true }], + templatesRenderAsynchronously: true, + selection: { mode: 'multiple' }, + stateStoring: { + enabled: true, + type: 'custom', + customLoad: () => Promise.resolve(RESTORED_STATE), + }, + } as DataGridProperties); + + await flushAsync(); + + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([1]); + }); + + it('should highlight the restored selected row whose checkbox is canceled in onEditorPreparing', async () => { + const { $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + selection: { mode: 'multiple' }, + onEditorPreparing: (e) => { + if (e.parentType === 'dataRow' && e.row?.key === 1) { + e.cancel = true; + } + }, + stateStoring: { + enabled: true, + type: 'custom', + customLoad: () => Promise.resolve(RESTORED_STATE), + }, + }); + + await flushAsync(); + renderPendingTemplates(); + + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([1]); + }); + + it('should highlight the rows selected with selectRows', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + selection: { mode: 'multiple' }, + }); + + const selecting = instance.selectRows([2], false); + jest.runAllTimers(); + renderPendingTemplates(); + await selecting; + + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([2]); + }); + + it('should highlight the selected row at its new position when the rows are sorted', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + selection: { mode: 'multiple' }, + }); + + const selecting = instance.selectRows([1], false); + jest.runAllTimers(); + instance.columnOption('id', 'sortOrder', 'desc'); + jest.runAllTimers(); + renderPendingTemplates(); + await selecting; + + expect(getRowKeys($container, 'tr.dx-data-row')).toEqual([3, 2, 1]); + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([1]); + }); + + it('should not highlight the rows of the page opened after the selection', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + dataSource: [...ROWS, { id: 4 }], + paging: { pageSize: 2 }, + selection: { mode: 'multiple' }, + }); + + const selecting = instance.selectRows([1], false); + jest.runAllTimers(); + const paging = instance.pageIndex(1); + jest.runAllTimers(); + renderPendingTemplates(); + await Promise.all([selecting, paging]); + + expect(getRowKeys($container, 'tr.dx-data-row')).toEqual([3, 4]); + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([]); + }); + + it('should highlight only the selected row when a row is inserted with repaintChangesOnly', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + dataSource: { + store: { type: 'array', key: 'id', data: ROWS.map((row) => ({ ...row })) }, + pushAggregationTimeout: 0, + }, + keyExpr: undefined, + repaintChangesOnly: true, + selection: { mode: 'multiple' }, + }); + + renderPendingTemplates(); + instance.getDataSource().store().push([{ type: 'insert', index: 0, data: { id: 10 } }]); + jest.runAllTimers(); + + const selecting = instance.selectRows([2], false); + jest.runAllTimers(); + renderPendingTemplates(); + await selecting; + + expect(getRowKeys($container, 'tr.dx-data-row')).toEqual([10, 1, 2, 3]); + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([2]); + }); + + it('should check the checkbox of the selected row updated after a row is inserted above it with repaintChangesOnly', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + dataSource: { + store: { + type: 'array', + key: 'id', + data: ROWS.map((row) => ({ ...row, name: 'name' })), + }, + pushAggregationTimeout: 0, + }, + keyExpr: undefined, + columns: [{ dataField: 'id', cellTemplate: 'cellTemplate' }, 'name'], + repaintChangesOnly: true, + selection: { mode: 'multiple' }, + }); + const store = instance.getDataSource().store(); + + renderPendingTemplates(); + store.push([{ type: 'insert', index: 0, data: { id: 10, name: 'name' } }]); + jest.runAllTimers(); + + const selecting = instance.selectRows([2], false); + jest.runAllTimers(); + store.push([{ type: 'update', key: 2, data: { name: 'updated name' } }]); + jest.runAllTimers(); + renderPendingTemplates(); + await selecting; + + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([2]); + expect(getRowKeys($container, '.dx-checkbox-checked')).toEqual([2]); + }); + + it('should highlight the appended rows selected with selectAll in the legacy infinite scrolling', async () => { + const { instance, $container, renderPendingTemplates } = await createGridWithPendingTemplates({ + ...LEGACY_INFINITE_SCROLLING_OPTIONS, + dataSource: [...ROWS, { id: 4 }], + paging: { pageSize: 2 }, + selection: { mode: 'multiple' }, + }); + + renderPendingTemplates(); + + const paging = instance.pageIndex(1); + jest.runAllTimers(); + const selecting = instance.selectAll(); + jest.runAllTimers(); + renderPendingTemplates(); + await Promise.all([paging, selecting]); + + expect(getRowKeys($container, 'tr.dx-data-row')).toEqual([1, 2, 3, 4]); + expect(getRowKeys($container, 'tr.dx-selection')).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.show_checkboxes_on_click.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.show_checkboxes_on_click.integration.test.ts new file mode 100644 index 000000000000..00a44fc14a68 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/selection/__tests__/selection.show_checkboxes_on_click.integration.test.ts @@ -0,0 +1,29 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; + +import { + afterTest, beforeTest, createDataGrid, flushAsync, +} from '../../__tests__/__mock__/helpers/utils'; +import { CHECKBOXES_HIDDEN_CLASS } from '../const'; + +describe('showCheckBoxesMode: onClick while new rows wait for async templates', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('should hide the row checkboxes once the rows are rendered', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }, { id: 2 }], + columns: [{ dataField: 'id', renderAsync: true }], + templatesRenderAsynchronously: true, + selection: { mode: 'multiple', showCheckBoxesMode: 'onClick' }, + } as DataGridProperties); + + await flushAsync(); + + const table = instance.getView('rowsView').getTableElement()?.get(0); + + expect(table?.classList).toContain(CHECKBOXES_HIDDEN_CLASS); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts b/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts index 1a46a2822b88..a36c1c47b191 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts @@ -5,6 +5,7 @@ import holdEvent from '@js/common/core/events/hold'; import { addNamespace, isCommandKeyPressed } from '@js/common/core/events/utils/index'; import messageLocalization from '@js/common/core/localization/message'; import { applyBatch } from '@js/common/data/array_utils'; +import { data as elementData } from '@js/core/element_data'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { equalByValue } from '@js/core/utils/common'; @@ -19,14 +20,14 @@ import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_col import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { ContextMenuController } from '@ts/grids/grid_core/context_menu/m_context_menu'; -import type { ModuleType } from '@ts/grids/grid_core/m_types'; +import type { ModuleType, RowKey } from '@ts/grids/grid_core/m_types'; import type { StateStoringController } from '@ts/grids/grid_core/state_storing/state_storing_controller_core'; import type { RowsView } from '@ts/grids/grid_core/views/m_rows_view'; import Selection from '@ts/ui/selection/selection'; import type { SelectionChangeEvent, SelectionFilter, SelectionOptions } from '@ts/ui/selection/types'; import type { DataController } from '../data_controller/data_controller'; -import type { DataChange } from '../data_controller/types'; +import type { DataChange, ProcessedItem } from '../data_controller/types'; import { isEditRow } from '../keyboard_navigation/utils'; import modules from '../m_modules'; import gridCoreUtils from '../m_utils'; @@ -42,6 +43,7 @@ import { SELECTION_MODE, SHOW_CHECKBOXES_MODE, } from './const'; +import { getSelectionCheckBox } from './utils'; const processLongTap = function (that, dxEvent) { // TODO getView @@ -828,13 +830,7 @@ export const selectionRowsViewExtender = ( return; } - const { isSelected } = changeItem; - const needSelectionClass = Boolean(isSelected) && !isEditRow($row); - - $row - .toggleClass(ROW_SELECTION_CLASS, needSelectionClass) - .find(`.${SELECT_CHECKBOX_CLASS}`).dxCheckBox('option', 'value', isSelected); - that.setAria('selected', String(isSelected), $row); + that.updateRowSelection($row, changeItem.isSelected); }); }); @@ -844,6 +840,22 @@ export const selectionRowsViewExtender = ( } } + private updateRowSelection($row: dxElementWrapper, isSelected?: boolean): void { + const needSelectionClass = Boolean(isSelected) && !isEditRow($row); + + $row.toggleClass(ROW_SELECTION_CLASS, needSelectionClass); + getSelectionCheckBox($row)?.option('value', isSelected); + this.setAria('selected', String(isSelected), $row); + } + + private isRowSelectionOutdated($row: dxElementWrapper, isSelected?: boolean): boolean { + const checkBox = getSelectionCheckBox($row); + const isAriaOutdated = $row.attr('aria-selected') !== String(isSelected); + const isCheckBoxOutdated = checkBox !== undefined && checkBox.option('value') !== isSelected; + + return isAriaOutdated || isCheckBoxOutdated; + } + protected _createTable() { const that = this; const selectionMode = that.option('selection.mode'); @@ -919,8 +931,50 @@ export const selectionRowsViewExtender = ( protected _renderCore(change) { const deferred = super._renderCore(change); - this._updateCheckboxesClass(); - return deferred; + const isWaitingForTemplates = this.isWaitingForAsyncTemplates(); + + return deferred.done(() => { + this._updateCheckboxesClass(); + + if (isWaitingForTemplates) { + this.syncRowsSelection(); + } + }); + } + + private syncRowsSelection(): void { + if (this.option(SELECTION_MODE) === 'none') { + return; + } + + const items = this._dataController.items(); + const findDataItem = (key: RowKey, rowIndex: number): ProcessedItem | undefined => { + const isRowItem = (item?: ProcessedItem): boolean => item?.rowType === 'data' + && equalByValue(item.key, key); + + return isRowItem(items[rowIndex]) ? items[rowIndex] : items.find(isRowItem); + }; + + const outdatedRows = this._getRowElements() + .toArray() + .flatMap((rowElement, rowIndex) => { + const rowOptions = elementData(rowElement, 'options'); + const item = rowOptions?.rowType === 'data' + ? findDataItem(rowOptions.key, rowIndex) + : undefined; + const isOutdated = item !== undefined + && this.isRowSelectionOutdated($(rowElement), item.isSelected); + + return isOutdated ? [{ rowIndex, isSelected: item.isSelected }] : []; + }); + + each(this.getTableElements(), (_, table) => { + const $rows = this._getRowElements($(table)); + + outdatedRows.forEach(({ rowIndex, isSelected }) => { + this.updateRowSelection($rows.eq(rowIndex), isSelected); + }); + }); } private _updateCheckboxesClass() { diff --git a/packages/devextreme/js/__internal/grids/grid_core/selection/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/selection/utils.ts index 29488e927b66..226f50d6adf7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/selection/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/selection/utils.ts @@ -1,9 +1,21 @@ +import type DOMComponent from '@js/core/dom_component'; +import type { dxElementWrapper } from '@js/core/renderer'; +import CheckBox from '@js/ui/check_box'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { ProcessedItem } from '@ts/grids/grid_core/data_controller/types'; +import { SELECT_CHECKBOX_CLASS } from '@ts/grids/grid_core/selection/const'; export const getSelectColumnIndex = (columns: Column[]): number => columns .findIndex((column) => column.command === 'select'); +export const getSelectionCheckBox = ( + $row: dxElementWrapper, +): DOMComponent | undefined => { + const $checkBox = $row.find(`.${SELECT_CHECKBOX_CLASS}`); + + return $checkBox.length > 0 ? CheckBox.getInstance($checkBox.get(0)) : undefined; +}; + export const updateSelectCellValue = ( currentItem: ProcessedItem, newItem: ProcessedItem,