diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts index 776cf1409c40..a5e6b7c5765a 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts @@ -1560,6 +1560,135 @@ test.meta({ browserSize: [800, 800] })('The page should not be changed when hidi delete (window as any).testLoadCount; })()); +test('New virtual mode. Virtual rows should not be rendered after the height is decreased and restored (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + const visibleRows = await dataGrid.apiGetVisibleRows(); + + await t + .expect(visibleRows.length) + .eql(10) + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2), + height: 600, + scrolling: { + mode: 'virtual', + }, +})); + +test('New virtual mode. Virtual rows should not be rendered after the container height is decreased and restored (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const setContainerHeight = ClientFunction((height: number) => { + $('#container').css('height', height); + }); + + await t.expect(dataGrid.isReady()).ok(); + + await setContainerHeight(150); + await dataGrid.apiUpdateDimensions(); + await setContainerHeight(600); + await dataGrid.apiUpdateDimensions(); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => { + await ClientFunction(() => { + $('#container').css('height', 600); + })(); + + return createWidget('dxDataGrid', { + dataSource: getData(10, 2), + scrolling: { + mode: 'virtual', + }, + }); +}); + +test('New virtual mode. Virtual rows should not be rendered after a row is selected while the height is decreased (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await t + .click(dataGrid.getDataRow(0).element) + .expect(dataGrid.getDataRow(0).isSelected) + .ok(); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2), + height: 600, + selection: { + mode: 'single', + }, + scrolling: { + mode: 'virtual', + }, +})); + +test('New virtual mode. Virtual rows should not be rendered after a row is focused while the height is decreased (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await t + .click(dataGrid.getDataRow(0).element) + .expect(dataGrid.getDataRow(0).isFocusedRow) + .ok(); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2).map((item, index) => ({ ...item, id: index })), + keyExpr: 'id', + height: 600, + focusedRowEnabled: true, + scrolling: { + mode: 'virtual', + }, +})); + fixture.disablePageReloads`Remote Scrolling` .page(url(__dirname, '../../container.html')); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts index 7d605345d194..5ddee1ff718b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts @@ -1,11 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; +import eventsEngine from '@js/common/core/events/core/events_engine'; +import { removeEvent } from '@js/common/core/events/remove'; import type { dxElementWrapper } from '@js/core/renderer'; -import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { Properties as DataGridProperties, RowPreparedEvent } from '@js/ui/data_grid'; import { - afterTest, beforeTest, createDataGrid, + afterTest, + beforeTest, + createDataGrid, + flushAsync, } from '../../__tests__/__mock__/helpers/utils'; interface TemplateArgs { @@ -96,3 +101,65 @@ describe('RowsView renderCompleted with async templates', () => { expect(rowKeysOnRenderCompleted).toEqual([FIRST_PAGE_ROW_KEYS]); }); }); + +describe('RowsView row changes', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + const CONTENT_HEIGHT = 500; + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const createGrid = async () => { + const dataSource = ROWS.map((row) => ({ ...row })); + const contentMinHeightsOnRowRemove: string[] = []; + + const grid = await createDataGrid({ + dataSource, + repaintChangesOnly: true, + onRowPrepared: ({ rowElement }: RowPreparedEvent) => { + eventsEngine.on(rowElement, removeEvent, () => { + const content = rowElement.closest('.dx-datagrid-rowsview .dx-datagrid-content'); + + if (content) { + contentMinHeightsOnRowRemove.push(content.style.minHeight); + } + }); + }, + }); + const content = (grid.$container.get(0) as HTMLElement) + .querySelector('.dx-datagrid-rowsview .dx-datagrid-content') as HTMLElement; + + content.style.height = `${CONTENT_HEIGHT}px`; + contentMinHeightsOnRowRemove.length = 0; + + const removeFirstRow = async (): Promise => { + dataSource.shift(); + const refreshing = grid.instance.refresh(true); + await flushAsync(); + await refreshing; + }; + + return { + ...grid, content, contentMinHeightsOnRowRemove, removeFirstRow, + }; + }; + + describe('when a rendered row is removed', () => { + it('should keep the content height while the row is removed', async () => { + const { contentMinHeightsOnRowRemove, removeFirstRow } = await createGrid(); + + await removeFirstRow(); + + expect(contentMinHeightsOnRowRemove).toEqual([`${CONTENT_HEIGHT}px`]); + }); + + it('should restore the content min-height after the row is removed', async () => { + const { content, removeFirstRow } = await createGrid(); + content.style.minHeight = '100px'; + + await removeFirstRow(); + + expect(content.style.minHeight).toBe('100px'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts index 90bf4b56755c..0b2a4db513f9 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts @@ -40,6 +40,7 @@ import { ColumnsView } from './m_columns_view'; import type { RowsViewScrollEvent } from './types'; import { getCellText, getMaxHorizontalScrollOffset, isRowElementVisible, isSameColumnLayout, + keepContentHeight, } from './utils'; const ROWS_VIEW_CLASS = 'rowsview'; @@ -459,77 +460,82 @@ export class RowsView extends ColumnsView { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return this.waitAsyncTemplates().done(() => { const contentChanges = this._contentChanges; + const hasRowChanges = contentChanges.some( + ({ change: contentChange }) => contentChange?.changeType === 'update', + ); this._contentChanges = []; - contentChanges.forEach(({ newTableElement, change, isFixedTableRendering }) => { - const tableElement = this.getTableElement(isFixedTableRendering); - const contentElement = this._findContentElement(isFixedTableRendering); - const changeType = change?.changeType; - const executors: any[] = []; - const highlightChanges = this.option('highlightChanges'); - const rowInsertedClass = this.addWidgetPrefix(ROW_INSERTED_ANIMATION_CLASS); - - switch (changeType) { - case 'update': - each(change.rowIndices, (index, rowIndex) => { - const $newRowElement = this._getRowElements(newTableElement).eq(index); - const dataChangeType = change.changeTypes?.[index]; - const item = change.items && change.items[index]; - - executors.push(() => { - const $rowElements = this._getRowElements(tableElement); - const $rowElement = $rowElements.eq(rowIndex); - - // eslint-disable-next-line default-case - switch (dataChangeType) { - case 'update': - if (item) { - const columnIndices = change.columnIndices?.[index]; - - if (isDefined(item.visible) && item.visible !== $rowElement.is(':visible')) { - $rowElement.toggle(item.visible); - } else if (columnIndices) { - this._updateCells($rowElement, $newRowElement, columnIndices, item); + keepContentHeight(hasRowChanges ? this._findContentElement() : undefined, () => { + contentChanges.forEach(({ newTableElement, change, isFixedTableRendering }) => { + const tableElement = this.getTableElement(isFixedTableRendering); + const contentElement = this._findContentElement(isFixedTableRendering); + const changeType = change?.changeType; + const executors: any[] = []; + const highlightChanges = this.option('highlightChanges'); + const rowInsertedClass = this.addWidgetPrefix(ROW_INSERTED_ANIMATION_CLASS); + + switch (changeType) { + case 'update': + each(change.rowIndices, (index, rowIndex) => { + const $newRowElement = this._getRowElements(newTableElement).eq(index); + const dataChangeType = change.changeTypes?.[index]; + const item = change.items && change.items[index]; + + executors.push(() => { + const $rowElements = this._getRowElements(tableElement); + const $rowElement = $rowElements.eq(rowIndex); + + // eslint-disable-next-line default-case + switch (dataChangeType) { + case 'update': + if (item) { + const columnIndices = change.columnIndices?.[index]; + + if (isDefined(item.visible) && item.visible !== $rowElement.is(':visible')) { + $rowElement.toggle(item.visible); + } else if (columnIndices) { + this._updateCells($rowElement, $newRowElement, columnIndices, item); + } else { + $rowElement.replaceWith($newRowElement); + } + } + break; + case 'insert': + if (!$rowElements.length) { + if (tableElement) { + const target = $newRowElement.is('tbody') ? tableElement : tableElement.children('tbody'); + $newRowElement.prependTo(target); + } + } else if ($rowElement.length) { + $newRowElement.insertBefore($rowElement); } else { - $rowElement.replaceWith($newRowElement); + $newRowElement.insertAfter($rowElements.last()); } - } - break; - case 'insert': - if (!$rowElements.length) { - if (tableElement) { - const target = $newRowElement.is('tbody') ? tableElement : tableElement.children('tbody'); - $newRowElement.prependTo(target); + if (highlightChanges && change.isLiveUpdate) { + $newRowElement.addClass(rowInsertedClass); } - } else if ($rowElement.length) { - $newRowElement.insertBefore($rowElement); - } else { - $newRowElement.insertAfter($rowElements.last()); - } - if (highlightChanges && change.isLiveUpdate) { - $newRowElement.addClass(rowInsertedClass); - } - break; - case 'remove': - $rowElement.remove(); - break; - } + break; + case 'remove': + $rowElement.remove(); + break; + } + }); + }); + each(executors, function () { + this(); }); - }); - each(executors, function () { - this(); - }); - newTableElement.remove(); - break; - default: - this.setTableElement(newTableElement, isFixedTableRendering); - contentElement.addClass(this.addWidgetPrefix(CONTENT_CLASS)); - this._setGridRole(contentElement); - this._renderContent(contentElement, newTableElement, isFixedTableRendering); - break; - } + newTableElement.remove(); + break; + default: + this.setTableElement(newTableElement, isFixedTableRendering); + contentElement.addClass(this.addWidgetPrefix(CONTENT_CLASS)); + this._setGridRole(contentElement); + this._renderContent(contentElement, newTableElement, isFixedTableRendering); + break; + } + }); }); }).fail(() => { this._contentChanges = []; diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts index 984fbd58754f..a2df81f3c1c7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts @@ -1,3 +1,5 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import { getOuterHeight } from '@js/core/utils/size'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import { AI_COLUMN_NAME } from '../ai_column/const'; @@ -32,3 +34,20 @@ export const isSameColumnLayout = ( export const isRowElementVisible = (rowElement: HTMLElement): boolean => ( rowElement.style.display !== 'none' && !rowElement.classList.contains(INVISIBLE_CLASS) ); + +export const keepContentHeight = ( + $content: dxElementWrapper | undefined, + callback: () => void, +): void => { + if (!$content) { + callback(); + + return; + } + + const { minHeight } = ($content.get(0) as HTMLElement).style; + + $content.css('minHeight', getOuterHeight($content)); + callback(); + $content.css('minHeight', minHeight); +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts new file mode 100644 index 000000000000..c117035c7efd --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts @@ -0,0 +1,138 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import type { DataGridScrollMode, Properties } from '@js/ui/data_grid'; +import { + afterTest, + beforeTest, + createDataGrid, + flushAsync, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { VirtualItemsCount } from '@ts/grids/grid_core/virtual_data_loader/types'; + +interface VirtualScrollingDataController { + viewportSize: (size?: number) => number; + virtualItemsCount: () => VirtualItemsCount | undefined; + loadViewport: () => void; +} + +const DATA = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, value: `row ${i + 1}` })); + +const createVirtualGrid = async ( + mode: DataGridScrollMode = 'virtual', + options: Properties = {}, +): ReturnType => { + const grid = await createDataGrid({ + dataSource: DATA, + height: 200, + paging: { pageSize: 10 }, + scrolling: { mode }, + ...options, + }); + await flushAsync(); + return grid; +}; + +describe('Virtual scrolling data controller — viewport reads', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('virtual items count after the viewport size changes (T1336247)', () => { + const allRowsOnOnePage: Properties = { + dataSource: DATA.slice(0, 12), + paging: { pageSize: 20 }, + }; + + const getDataController = ( + instance: Awaited>['instance'], + ): VirtualScrollingDataController => instance + .getController('data') as unknown as VirtualScrollingDataController; + + it('describes the rendered rows when the viewport shrinks without re-rendering', async () => { + const { instance } = await createVirtualGrid('virtual', allRowsOnOnePage); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + + it('keeps the bottom virtual items for the rows that are not rendered', async () => { + const { instance } = await createVirtualGrid(); + const dataController = getDataController(instance); + const renderedRowCount = instance.getVisibleRows().length; + + dataController.viewportSize(4); + + expect(dataController.virtualItemsCount()).toEqual({ + begin: 0, + end: DATA.length - renderedRowCount, + }); + }); + + it('describes the rendered rows of a non-first page when only rows are rendered virtually', async () => { + const { instance } = await createVirtualGrid('standard', { + paging: { pageSize: 20 }, + scrolling: { mode: 'standard', rowRenderingMode: 'virtual' }, + }); + const dataController = getDataController(instance); + + const paging = instance.pageIndex(1); + await flushAsync(); + await paging; + + const renderedRowCount = instance.getVisibleRows().length; + + expect(instance.getVisibleRows()[0].key).toBe(21); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 20 - renderedRowCount }); + }); + + it('follows the viewport after the rows are re-rendered', async () => { + const { instance } = await createVirtualGrid('virtual', allRowsOnOnePage); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + dataController.loadViewport(); + await flushAsync(); + + const renderedRowCount = instance.getVisibleRows().length; + + expect(renderedRowCount).toBeLessThan(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 12 - renderedRowCount }); + }); + + it('describes the rendered rows after a row is selected while the viewport is shrunk', async () => { + const { instance } = await createVirtualGrid('virtual', { + ...allRowsOnOnePage, + selection: { mode: 'single' }, + }); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + const selecting = instance.selectRows([1], false); + await flushAsync(); + await selecting; + + expect(instance.isRowSelected(1)).toBe(true); + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + + it('describes the rendered rows after the focused row changes while the viewport is shrunk', async () => { + const { instance } = await createVirtualGrid('virtual', { + ...allRowsOnOnePage, + focusedRowEnabled: true, + }); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + instance.option('focusedRowKey', 1); + await flushAsync(); + + expect(instance.option('focusedRowIndex')).toBe(0); + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts index 8903563cd5e9..99445341a4b1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts @@ -188,6 +188,9 @@ export const virtualScrollingDataControllerExtender = ( loadedItemCount() { return that._itemCount; }, + renderedItemsRange() { + return that.getRenderedItemsRange(); + }, totalItemsCount() { if (isVirtualPaging(that)) { return that.totalItemsCount(); @@ -355,6 +358,26 @@ export const virtualScrollingDataControllerExtender = ( this._loadViewportParams = viewportParams; } + private getRenderedItemsRange(): { skip: number; take: number } | undefined { + const renderedItems = this._items; + const loadedItems: ProcessedItem[] | null = this._allItems; + + if (!renderedItems.length || !loadedItems?.length) { + return undefined; + } + + const loadedItemIndex = loadedItems[0].loadIndex ?? 0; + const renderedItemIndex = renderedItems[0].loadIndex ?? 0; + const pageStartItemIndex = isVirtualPaging(this) ? 0 : loadedItemIndex; + const firstItemIndex = renderedItemIndex - pageStartItemIndex; + const lastItemIndex = (renderedItems[renderedItems.length - 1].loadIndex ?? 0) - pageStartItemIndex; + + return { + skip: firstItemIndex, + take: lastItemIndex - firstItemIndex + 1, + }; + } + protected _processItems(items: RawItemData[], change: DataChange): ProcessedItem[] { const processedItems = super._processItems(items, change); diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts index eac3dca33055..536148a51080 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts @@ -173,12 +173,13 @@ class VirtualScrollController { const dataOptions = this._dataOptions; const totalItemsCount = dataOptions.totalItemsCount(); if (this.option(LEGACY_SCROLLING_MODE) === false && totalItemsCount !== -1) { + const renderedItemsRange = dataOptions.renderedItemsRange?.(); const viewportParams = this.getViewportParams(); const loadedOffset = dataOptions.loadedOffset(); const loadedItemCount = dataOptions.loadedItemCount(); - const skip = Math.max(viewportParams.skip, loadedOffset); - const take = Math.min(viewportParams.take, loadedItemCount); + const skip = renderedItemsRange?.skip ?? Math.max(viewportParams.skip, loadedOffset); + const take = renderedItemsRange?.take ?? Math.min(viewportParams.take, loadedItemCount); const endItemsCount = Math.max(totalItemsCount - (skip + take), 0); return { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js index ff4057a2e1ca..8c3d5c8124f4 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js @@ -5900,7 +5900,7 @@ QUnit.module('Virtual Scrolling', baseModuleConfig, () => { this.clock.tick(300); - for(let i = 0; i < 7; i++) { + for(let i = 0; i < 10; i++) { dataGrid.getScrollable().scrollTo({ top: 16000000 }); this.clock.tick(1000); }