diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/const.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/const.ts index 4eb753f09646..8600b865d7ca 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/const.ts @@ -8,4 +8,9 @@ export const CLICK_EVENT = addNamespace(pointerEvents.down, EVENT_NAMESPACE); export const NAV_KEYS = { upArrow: 'ArrowUp', downArrow: 'ArrowDown', + leftArrow: 'ArrowLeft', + rightArrow: 'ArrowRight', + home: 'Home', + end: 'End', + pageDown: 'PageDown', } as const; diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/utils.ts index 2d3a9f21ca9a..36cdeb038969 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/helpers/utils.ts @@ -1,5 +1,7 @@ +import { jest } from '@jest/globals'; import eventsEngine from '@js/common/core/events/core/events_engine'; import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; import { keyboard } from '@ts/events/m_short'; import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; @@ -15,21 +17,44 @@ export function triggerPointerDown(element: HTMLElement): void { eventsEngine.trigger($(element), CLICK_EVENT); } -export function triggerKeyDown(instance: DataGridInstance, keyName: string): void { +export interface KeyDownEventSpies { + preventDefault: jest.Mock<() => void>; + stopPropagation: jest.Mock<() => void>; +} + +export function triggerKeyDown( + instance: DataGridInstance, + keyName: string, + target?: Element | null, + modifiers: Partial> = {}, +): KeyDownEventSpies { const controller = getKeyboardNavigationController(instance); const listenerId = controller.keyDownListener; - keyboard._getProcessor(listenerId).process({ + const processor = keyboard._getProcessor(listenerId); + + if (!processor) { + throw new Error(`There is no keyboard processor with the '${listenerId}' id`); + } + + const preventDefault = jest.fn<() => void>(); + const stopPropagation = jest.fn<() => void>(); + const event = { key: NAV_KEYS[keyName] ?? keyName, keyName, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, - target: controller._getFocusedCell()?.get(0) ?? null, + ...modifiers, + target: target ?? controller._getFocusedCell()?.get(0) ?? null, type: 'keydown', - preventDefault() {}, + preventDefault, isDefaultPrevented() { return false; }, - stopPropagation() {}, - }); + stopPropagation, + } as unknown as DxEvent; + + processor.process(event); + + return { preventDefault, stopPropagation }; } diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/keyboard_navigation.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/keyboard_navigation.integration.test.ts index dbd17434337e..deedbdabef32 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/keyboard_navigation.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/__tests__/keyboard_navigation.integration.test.ts @@ -1,6 +1,8 @@ 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 type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; import { afterTest, @@ -19,6 +21,16 @@ import { type KeyboardNavKey = keyof typeof NAV_KEYS; type SelectionMode = 'single' | 'multiple' | 'none'; +interface AsyncTemplateArgs { + model: { value: unknown }; + container: HTMLElement; + onRendered?: () => void; +} + +type GridWithPendingTemplates = Awaited> & { + renderPendingTemplates: () => void; +}; + describe('Keyboard Navigation', () => { beforeEach(beforeTest); afterEach(afterTest); @@ -31,6 +43,20 @@ describe('Keyboard Navigation', () => { { id: 5, name: 'Item 5', group: 'C' }, ]; + const EDITING_OPTIONS: DataGridProperties = { + editing: { mode: 'cell', allowUpdating: true, startEditAction: 'dblClick' }, + keyboardNavigation: { editOnKeyPress: true }, + }; + const SELECTION_OPTIONS: DataGridProperties = { + selection: { mode: 'multiple', showCheckBoxesMode: 'none' }, + }; + + const getRowKey = (element: Element | null): number | undefined => { + const row = element?.closest('tr.dx-data-row'); + + return row ? Number(row.cells[0].textContent) : undefined; + }; + describe('from focused expand command cell with batch editing (T1322130)', () => { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const createGridWithGrouping = (selectionMode: SelectionMode) => createDataGrid({ @@ -363,4 +389,783 @@ describe('Keyboard Navigation', () => { expect(keyboardNav._focusedCellPosition).toEqual({ rowIndex: 1, columnIndex: 1 }); }); }); + + describe('Keys pressed with virtual scrolling while the scrolled-in rows wait for async templates (T1336240)', () => { + const ROWS_KEYED_BY_INDEX = Array.from({ length: 200 }, (_, index) => ({ id: index, name: `Row ${index}` })); + const FOCUSED_ROW_KEY = 5; + const NEXT_ROW_KEY = FOCUSED_ROW_KEY + 1; + const SCROLL_TOP = 60; + + const getRowKeys = ($container: dxElementWrapper, selector: string): (number | undefined)[] => { + const container = $container.get(0) as HTMLElement; + + return Array.from(container.querySelectorAll(`.dx-datagrid-rowsview ${selector}`), getRowKey); + }; + + const focusRowAndScrollDown = async ( + focusedRowEnabled: boolean, + columnIndex = 1, + options: DataGridProperties = {}, + ): Promise => { + const pendingTemplates: (() => void)[] = []; + const renderPendingTemplates = (): void => { + while (pendingTemplates.length) { + pendingTemplates.shift()?.(); + jest.runAllTimers(); + } + }; + + const grid = await createDataGrid({ + dataSource: ROWS_KEYED_BY_INDEX, + height: 400, + focusedRowEnabled, + scrolling: { mode: 'virtual', renderAsync: true }, + columns: ['id', { dataField: 'name', cellTemplate: 'asyncTemplate' }], + templatesRenderAsynchronously: true, + integrationOptions: { + templates: { + asyncTemplate: { + render({ model, container, onRendered }: AsyncTemplateArgs): void { + pendingTemplates.push(() => { + container.append(String(model.value)); + onRendered?.(); + }); + }, + }, + }, + }, + ...options, + } as DataGridProperties); + renderPendingTemplates(); + + const cell = grid.component.getDataCell(FOCUSED_ROW_KEY, columnIndex); + triggerPointerDown(cell.getElement() as HTMLElement); + jest.runAllTimers(); + + const dataController = grid.instance.getController('data'); + // @ts-expect-error + dataController.setViewportPosition(SCROLL_TOP); + jest.runAllTimers(); + + expect(dataController.getRowIndexOffset()).toBeGreaterThan(0); + expect(getRowKeys(grid.$container, 'tr.dx-data-row')[0]).toBe(0); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + + return { ...grid, renderPendingTemplates }; + }; + + const VERTICAL_NAVIGATION_CASES = ['downArrow', 'upArrow'].flatMap( + (keyName) => [true, false].map((focusedRowEnabled) => ({ keyName, focusedRowEnabled })), + ); + + it.each(VERTICAL_NAVIGATION_CASES)( + 'should ignore $keyName pressed while the rows are rendering (focusedRowEnabled: $focusedRowEnabled)', + async ({ keyName, focusedRowEnabled }) => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown(focusedRowEnabled); + + triggerKeyDown(instance, keyName, document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + + renderPendingTemplates(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + }, + ); + + it.each([true, false])( + 'should focus the next row on downArrow pressed once the rows are rendered (focusedRowEnabled: %s)', + async (focusedRowEnabled) => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown(focusedRowEnabled); + + renderPendingTemplates(); + triggerKeyDown(instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(NEXT_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(NEXT_ROW_KEY); + }, + ); + + interface HorizontalNavigationCase { + keyName: KeyboardNavKey; + startColumnIndex: number; + focusedRowEnabled: boolean; + } + + const HORIZONTAL_NAVIGATION_CASES = [ + { keyName: 'rightArrow', startColumnIndex: 0 }, + { keyName: 'leftArrow', startColumnIndex: 1 }, + { keyName: 'end', startColumnIndex: 0 }, + { keyName: 'home', startColumnIndex: 1 }, + ].flatMap((navigationCase) => [true, false].map((focusedRowEnabled) => ({ + ...navigationCase, + focusedRowEnabled, + }))) as HorizontalNavigationCase[]; + + it.each(HORIZONTAL_NAVIGATION_CASES)( + 'should keep the focused cell on $keyName pressed while the rows are rendering (focusedRowEnabled: $focusedRowEnabled)', + async ({ keyName, startColumnIndex, focusedRowEnabled }) => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + focusedRowEnabled, + startColumnIndex, + ); + const focusedColumnIndex = instance.option('focusedColumnIndex'); + const focusedCellIndex = document.activeElement?.closest('td')?.cellIndex; + + triggerKeyDown(instance, keyName, document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + + renderPendingTemplates(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('focusedColumnIndex')).toBe(focusedColumnIndex); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + expect(document.activeElement?.closest('td')?.cellIndex).toBe(focusedCellIndex); + }, + ); + + it.each([ + { keyName: 'rightArrow', startColumnIndex: 0, expectedColumnIndex: 1 }, + { keyName: 'home', startColumnIndex: 1, expectedColumnIndex: 0 }, + ])( + 'should focus the cell in column $expectedColumnIndex on $keyName pressed once the rows are rendered', + async ({ keyName, startColumnIndex, expectedColumnIndex }) => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + startColumnIndex, + ); + + renderPendingTemplates(); + triggerKeyDown(instance, keyName, document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('focusedColumnIndex')).toBe(expectedColumnIndex); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + expect(document.activeElement?.closest('td')?.cellIndex).toBe(expectedColumnIndex); + }, + ); + + it('should keep focusedRowIndex on Ctrl+downArrow pressed while the rows are rendering (focusedRowEnabled: false)', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown(false); + + triggerKeyDown(instance, 'downArrow', document.activeElement, { ctrlKey: true }); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + }); + + it('should keep highlighting the focused row while the rows are rendering and once they are rendered', async () => { + const { instance, $container, renderPendingTemplates } = await focusRowAndScrollDown(true); + + triggerKeyDown(instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + + expect(getRowKeys($container, 'tr.dx-row-focused')).toEqual([FOCUSED_ROW_KEY]); + + renderPendingTemplates(); + + expect(instance.option('focusedRowKey')).toBe(FOCUSED_ROW_KEY); + expect(getRowKeys($container, 'tr.dx-row-focused')).toEqual([FOCUSED_ROW_KEY]); + }); + + it.each(['enter', 'F2', 'x'])( + 'should not start editing on %s pressed while the rows are rendering', + async (keyName) => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + 1, + EDITING_OPTIONS, + ); + + triggerKeyDown(instance, keyName, document.activeElement); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(instance.getController('editing').isEditing()).toBe(false); + }, + ); + + it('should not start editing on Meta+x pressed while the rows are rendering', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + 1, + EDITING_OPTIONS, + ); + + triggerKeyDown(instance, 'x', document.activeElement, { metaKey: true }); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(instance.getController('editing').isEditing()).toBe(false); + }); + + it('should select all rows on Meta+A pressed while the rows are rendering', async () => { + const { instance } = await focusRowAndScrollDown(false, 1, { + ...EDITING_OPTIONS, + ...SELECTION_OPTIONS, + }); + + triggerKeyDown(instance, 'a', document.activeElement, { metaKey: true }); + jest.runAllTimers(); + + expect(instance.getSelectedRowKeys()).toHaveLength(ROWS_KEYED_BY_INDEX.length); + expect(instance.getController('editing').isEditing()).toBe(false); + }); + + it('should focus the search panel on Meta+F pressed while the rows are rendering', async () => { + const { instance, $container } = await focusRowAndScrollDown(false, 1, { + ...EDITING_OPTIONS, + searchPanel: { visible: true }, + }); + + triggerKeyDown(instance, 'f', document.activeElement, { metaKey: true }); + jest.runAllTimers(); + + const searchInput = ($container.get(0) as HTMLElement).querySelector('.dx-datagrid-search-panel input'); + expect(document.activeElement).toBe(searchInput); + expect(instance.getController('editing').isEditing()).toBe(false); + }); + + it('should start editing the focused cell on enter pressed once the rows are rendered', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + 1, + EDITING_OPTIONS, + ); + + renderPendingTemplates(); + triggerKeyDown(instance, 'enter', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('editing.editRowKey')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('editing.editColumnName')).toBe('name'); + }); + + it('should not select a row on space pressed while the rows are rendering', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + 1, + SELECTION_OPTIONS, + ); + + triggerKeyDown(instance, 'space', document.activeElement); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(instance.getSelectedRowKeys()).toEqual([]); + }); + + it('should select the focused row on space pressed once the rows are rendered', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + false, + 1, + SELECTION_OPTIONS, + ); + + renderPendingTemplates(); + triggerKeyDown(instance, 'space', document.activeElement); + jest.runAllTimers(); + + expect(instance.getSelectedRowKeys()).toEqual([FOCUSED_ROW_KEY]); + }); + + it('should ignore tab pressed while the rows are rendering', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown(false, 0); + + triggerKeyDown(instance, 'tab', document.activeElement); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('focusedColumnIndex')).toBe(0); + }); + + it('should focus the next cell on tab pressed once the rows are rendered', async () => { + const { instance, renderPendingTemplates } = await focusRowAndScrollDown(false, 0); + + renderPendingTemplates(); + triggerKeyDown(instance, 'tab', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('focusedColumnIndex')).toBe(1); + }); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const recordFocusChangingEvents = () => { + const raisedEvents: string[] = []; + const options: DataGridProperties = { + onFocusedCellChanging: (): void => { raisedEvents.push('onFocusedCellChanging'); }, + onFocusedRowChanging: (): void => { raisedEvents.push('onFocusedRowChanging'); }, + }; + + return { raisedEvents, options }; + }; + + it.each([true, false])( + 'should not raise onFocusedCellChanging and onFocusedRowChanging for downArrow pressed while the rows are rendering (focusedRowEnabled: %s)', + async (focusedRowEnabled) => { + const { raisedEvents, options } = recordFocusChangingEvents(); + const { instance, renderPendingTemplates } = await focusRowAndScrollDown( + focusedRowEnabled, + 1, + options, + ); + raisedEvents.length = 0; + + triggerKeyDown(instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + renderPendingTemplates(); + + expect(raisedEvents).toEqual([]); + }, + ); + + it('should prevent the default action and stop the propagation of downArrow pressed while the rows are rendering', async () => { + const { instance } = await focusRowAndScrollDown(false); + + const { preventDefault, stopPropagation } = triggerKeyDown( + instance, + 'downArrow', + document.activeElement, + ); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + }); + + describe('Keys pressed while re-rendered rows wait for async templates', () => { + const ROWS = Array.from({ length: 200 }, (_, index) => ({ id: index, name: `Row ${index}`, price: index })); + const FOCUSED_ROW_KEY = 5; + const NEXT_ROW_KEY = FOCUSED_ROW_KEY + 1; + const UPDATED_ROW_KEY = 15; + const LAST_COLUMN_INDEX = 2; + const SCROLL_TOP = 60; + + type RowsChange = (instance: DataGridInstance) => void; + + const pushUpdate: RowsChange = (instance) => { + instance.getDataSource().store().push([ + { type: 'update', key: UPDATED_ROW_KEY, data: { price: 1000 } }, + ]); + }; + + const createGrid = async ( + options: DataGridProperties = {}, + ): Promise => { + const pendingTemplates: (() => void)[] = []; + const renderPendingTemplates = (): void => { + while (pendingTemplates.length) { + pendingTemplates.shift()?.(); + jest.runAllTimers(); + } + }; + + const grid = await createDataGrid({ + dataSource: ROWS.map((row) => ({ ...row })), + repaintChangesOnly: true, + columns: [ + 'id', + { dataField: 'name', cellTemplate: 'asyncTemplate' }, + { dataField: 'price', cellTemplate: 'asyncTemplate' }, + ], + templatesRenderAsynchronously: true, + integrationOptions: { + templates: { + asyncTemplate: { + render({ model, container, onRendered }: AsyncTemplateArgs): void { + pendingTemplates.push(() => { + container.append(String(model.value)); + onRendered?.(); + }); + }, + }, + }, + }, + ...options, + } as DataGridProperties); + renderPendingTemplates(); + + return { ...grid, renderPendingTemplates }; + }; + + const focusCell = ( + { instance, component }: GridWithPendingTemplates, + rowIndex: number, + columnIndex: number, + ): void => { + const cell = component.getDataCell(rowIndex, columnIndex); + triggerPointerDown(cell.getElement() as HTMLElement); + jest.runAllTimers(); + + expect(document.activeElement?.closest('tr')).toBe(instance.getRowElement(rowIndex)?.[0]); + }; + + const changeRows = (instance: DataGridInstance, change: RowsChange): void => { + change(instance); + jest.runAllTimers(); + + expect(instance.getView('rowsView').isWaitingForAsyncTemplates()).toBe(true); + }; + + interface SameRowsChangeCase { + change: string; + options: DataGridProperties; + act: RowsChange; + } + + const SAME_ROWS_CHANGES: SameRowsChangeCase[] = [ + { change: 'an update of another row', options: {}, act: pushUpdate }, + { change: 'an update that re-renders all rows', options: { repaintChangesOnly: false }, act: pushUpdate }, + { + change: 'a refresh', + options: {}, + act: (instance): void => { instance.refresh().catch(() => {}); }, + }, + ]; + + const focusCellAndChangeRows = async ( + { options, act }: SameRowsChangeCase, + columnIndex: number, + extraOptions: DataGridProperties = {}, + ): Promise => { + const grid = await createGrid({ ...options, ...extraOptions }); + focusCell(grid, FOCUSED_ROW_KEY, columnIndex); + changeRows(grid.instance, act); + + return grid; + }; + + it.each(SAME_ROWS_CHANGES.flatMap((changeCase) => [true, false].map((focusedRowEnabled) => ({ + ...changeCase, + focusedRowEnabled, + }))))( + 'should focus the next row on downArrow pressed while $change is rendering (focusedRowEnabled: $focusedRowEnabled)', + async ({ focusedRowEnabled, ...changeCase }) => { + const { instance, renderPendingTemplates } = await focusCellAndChangeRows( + changeCase, + 1, + { focusedRowEnabled }, + ); + + triggerKeyDown(instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(NEXT_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(NEXT_ROW_KEY); + + renderPendingTemplates(); + + expect(getRowKey(document.activeElement)).toBe(NEXT_ROW_KEY); + }, + ); + + it.each(SAME_ROWS_CHANGES)( + 'should focus the next cell on rightArrow pressed while $change is rendering', + async (changeCase) => { + const { instance, renderPendingTemplates } = await focusCellAndChangeRows(changeCase, 0); + + triggerKeyDown(instance, 'rightArrow', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedColumnIndex')).toBe(1); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + expect(document.activeElement?.closest('td')?.cellIndex).toBe(1); + + renderPendingTemplates(); + + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + expect(document.activeElement?.closest('td')?.cellIndex).toBe(1); + }, + ); + + it.each(SAME_ROWS_CHANGES)( + 'should focus the first cell of the next row on tab pressed in the last column while $change is rendering', + async (changeCase) => { + const { instance } = await focusCellAndChangeRows(changeCase, LAST_COLUMN_INDEX); + + triggerKeyDown(instance, 'tab', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('focusedRowIndex')).toBe(NEXT_ROW_KEY); + expect(instance.option('focusedColumnIndex')).toBe(0); + expect(getRowKey(document.activeElement)).toBe(NEXT_ROW_KEY); + }, + ); + + it.each(SAME_ROWS_CHANGES)( + 'should start editing the focused cell on enter pressed while $change is rendering', + async (changeCase) => { + const { instance } = await focusCellAndChangeRows(changeCase, 1, EDITING_OPTIONS); + + triggerKeyDown(instance, 'enter', document.activeElement); + jest.runAllTimers(); + + expect(instance.option('editing.editRowKey')).toBe(FOCUSED_ROW_KEY); + expect(instance.option('editing.editColumnName')).toBe('name'); + }, + ); + + it.each(SAME_ROWS_CHANGES)( + 'should select the focused row on space pressed while $change is rendering', + async (changeCase) => { + const { instance } = await focusCellAndChangeRows(changeCase, 1, SELECTION_OPTIONS); + + triggerKeyDown(instance, 'space', document.activeElement); + jest.runAllTimers(); + + expect(instance.getSelectedRowKeys()).toEqual([FOCUSED_ROW_KEY]); + }, + ); + + interface SameRowsLayoutCase { + layout: string; + options: DataGridProperties; + focusedRowIndex: number; + prepare?: (grid: GridWithPendingTemplates) => Promise; + } + + it.each([ + { + layout: 'grouped rows', + options: { + customizeColumns: (columns): void => { + columns[2].groupIndex = 0; + columns[2].calculateGroupValue = (): string => 'All'; + }, + }, + focusedRowIndex: FOCUSED_ROW_KEY + 1, + }, + { + layout: 'legacy fixed columns', + options: { + // @ts-expect-error private option + columnFixing: { legacyMode: true }, + customizeColumns: (columns): void => { columns[0].fixed = true; }, + }, + focusedRowIndex: FOCUSED_ROW_KEY, + }, + { + layout: 'an expanded detail row', + options: { + masterDetail: { + enabled: true, + template: (container: HTMLElement): void => { container.append('Detail'); }, + }, + }, + focusedRowIndex: FOCUSED_ROW_KEY, + prepare: async ({ instance, renderPendingTemplates }): Promise => { + const expanding = instance.expandRow(UPDATED_ROW_KEY); + jest.runAllTimers(); + renderPendingTemplates(); + await expanding; + }, + }, + ])( + 'should focus the next row on downArrow pressed while an update re-renders $layout', + async ({ options, focusedRowIndex, prepare }) => { + const grid = await createGrid({ + ...options, + repaintChangesOnly: false, + focusedRowEnabled: true, + }); + await prepare?.(grid); + focusCell(grid, focusedRowIndex, 1); + changeRows(grid.instance, pushUpdate); + + triggerKeyDown(grid.instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + + expect(grid.instance.option('focusedRowIndex')).toBe(focusedRowIndex + 1); + expect(document.activeElement?.closest('tr')) + .toBe(grid.instance.getRowElement(focusedRowIndex + 1)?.[0]); + }, + ); + + interface RowsLayoutChangeCase { + change: string; + options: DataGridProperties; + act: RowsChange; + } + + it.each([ + { + change: 'an inserted row', + options: {}, + act: (instance): void => { + instance.getDataSource().store().push([{ + type: 'insert', + data: { id: ROWS.length, name: 'New row', price: 0 }, + index: UPDATED_ROW_KEY, + }]); + }, + }, + { + change: 'an update queued after a scroll', + options: { height: 400, scrolling: { mode: 'virtual', renderAsync: true } }, + act: (instance): void => { + // @ts-expect-error + instance.getController('data').setViewportPosition(SCROLL_TOP); + jest.runAllTimers(); + pushUpdate(instance); + }, + }, + ])('should ignore downArrow pressed while $change is rendering', async ({ options, act }) => { + const grid = await createGrid(options); + focusCell(grid, FOCUSED_ROW_KEY, 1); + changeRows(grid.instance, act); + + triggerKeyDown(grid.instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + grid.renderPendingTemplates(); + + expect(grid.instance.option('focusedRowIndex')).toBe(FOCUSED_ROW_KEY); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + }); + + it('should ignore downArrow pressed in a master row while its collapsed detail row is rendering', async () => { + const grid = await createGrid({ + repaintChangesOnly: false, + masterDetail: { + enabled: true, + template: (container: HTMLElement): void => { container.append('Detail'); }, + }, + }); + const expanding = grid.instance.expandRow(UPDATED_ROW_KEY); + jest.runAllTimers(); + grid.renderPendingTemplates(); + await expanding; + focusCell(grid, UPDATED_ROW_KEY, 1); + changeRows(grid.instance, (instance): void => { + instance.collapseRow(UPDATED_ROW_KEY).catch(() => {}); + }); + + triggerKeyDown(grid.instance, 'downArrow', document.activeElement); + jest.runAllTimers(); + grid.renderPendingTemplates(); + + expect(grid.instance.option('focusedRowIndex')).toBe(UPDATED_ROW_KEY); + expect(document.activeElement?.closest('tr')).toBe(grid.instance.getRowElement(UPDATED_ROW_KEY)?.[0]); + }); + + it('should ignore leftArrow pressed while the rows without a hidden column are rendering', async () => { + const grid = await createGrid(); + focusCell(grid, FOCUSED_ROW_KEY, LAST_COLUMN_INDEX); + changeRows(grid.instance, (instance): void => { + instance.columnOption('name', 'visible', false); + }); + + triggerKeyDown(grid.instance, 'leftArrow', document.activeElement); + jest.runAllTimers(); + + expect(document.activeElement?.closest('td')?.cellIndex).toBe(LAST_COLUMN_INDEX); + + grid.renderPendingTemplates(); + + expect(grid.instance.option('focusedColumnIndex')).toBe(1); + expect(getRowKey(document.activeElement)).toBe(FOCUSED_ROW_KEY); + expect(document.activeElement?.closest('td')?.cellIndex).toBe(1); + }); + }); + + describe('Focus after the rows are re-rendered with async templates', () => { + const PAGE_SIZE = 3; + const ROWS = Array.from({ length: PAGE_SIZE * 2 }, (_, index) => ({ id: index, name: `Row ${index}` })); + const FOCUSED_COLUMN_INDEX = 1; + + const getFocusedCell = (): { rowKey: number; columnIndex: number } | undefined => { + const cell = document.activeElement?.closest('td'); + const row = cell?.closest('tr.dx-data-row'); + + return cell && row + ? { rowKey: Number(row.cells[0].textContent), columnIndex: cell.cellIndex } + : undefined; + }; + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const createGridWithFocusedCell = async () => { + const pendingTemplates: (() => void)[] = []; + const renderPendingTemplates = (): void => { + while (pendingTemplates.length) { + pendingTemplates.shift()?.(); + jest.runAllTimers(); + } + }; + + const grid = await createDataGrid({ + dataSource: ROWS, + paging: { pageSize: PAGE_SIZE }, + columns: ['id', { dataField: 'name', cellTemplate: 'asyncTemplate' }], + templatesRenderAsynchronously: true, + integrationOptions: { + templates: { + asyncTemplate: { + render({ model, container, onRendered }: AsyncTemplateArgs): void { + pendingTemplates.push(() => { + container.append(String(model.value)); + onRendered?.(); + }); + }, + }, + }, + }, + } as DataGridProperties); + renderPendingTemplates(); + + const cell = grid.component.getDataCell(0, FOCUSED_COLUMN_INDEX); + triggerPointerDown(cell.getElement() as HTMLElement); + jest.runAllTimers(); + + return { ...grid, renderPendingTemplates }; + }; + + interface ReRenderCase { + action: string; + act: (instance: DataGridInstance) => Promise | void; + expectedRowKey: number; + } + + it.each([ + { + action: 'refresh', + act: (instance): Promise => instance.refresh(), + expectedRowKey: 0, + }, + { + action: 'repaint', + act: (instance): void => { instance.repaint(); }, + expectedRowKey: 0, + }, + { + action: 'pageDown', + act: (instance): void => { triggerKeyDown(instance, 'pageDown', document.activeElement); }, + expectedRowKey: PAGE_SIZE, + }, + ])( + 'should focus the cell in row $expectedRowKey after $action once the new rows are rendered', + async ({ act, expectedRowKey }) => { + const { instance, renderPendingTemplates } = await createGridWithFocusedCell(); + + const acting = act(instance); + jest.runAllTimers(); + renderPendingTemplates(); + await acting; + + expect(getFocusedCell()).toEqual({ + rowKey: expectedRowKey, + columnIndex: FOCUSED_COLUMN_INDEX, + }); + }, + ); + }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/const.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/const.ts index 4b278b4398e2..e347ff6c1f9f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/const.ts @@ -52,6 +52,10 @@ export const FOCUS_TYPE_CELL = 'cell'; export const COLUMN_HEADERS_VIEW = 'columnHeadersView'; export const ROWS_VIEW = 'rowsView'; export const FUNCTIONAL_KEYS = ['shift', 'control', 'alt']; +export const FOCUSED_CELL_KEYS = [ + 'upArrow', 'downArrow', 'leftArrow', 'rightArrow', 'home', 'end', + 'tab', 'enter', 'space', 'F2', 'del', 'backspace', +]; export const DRAG_COLUMN_NAME = 'drag'; export enum Direction { diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts index adcddd31b7fd..9330af94c134 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts @@ -24,6 +24,7 @@ import { isDeferred, isDefined, isEmptyObject } from '@js/core/utils/type'; import * as accessibility from '@js/ui/shared/accessibility'; import { isElementInDom } from '@ts/core/utils/m_dom'; import { focused } from '@ts/core/utils/m_selectors'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import type { AdaptiveColumnsController } from '@ts/grids/grid_core/adaptivity/m_adaptivity'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; @@ -70,6 +71,7 @@ import { FOCUS_STATE_CLASS, FOCUS_TYPE_CELL, FOCUS_TYPE_ROW, + FOCUSED_CELL_KEYS, FOCUSED_CLASS, FREESPACE_ROW_CLASS, FUNCTIONAL_KEYS, @@ -553,8 +555,17 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return; } - !FUNCTIONAL_KEYS.includes(e.keyName) - && this._updateFocusedCellPositionByTarget(originalEvent.target); + const isWaitingForRows = this.isWaitingForRowsRendering(e); + + if (!FUNCTIONAL_KEYS.includes(e.keyName) && !isWaitingForRows) { + this._updateFocusedCellPositionByTarget(originalEvent.target); + } + + if (isWaitingForRows && !isHandled && this.isFocusedCellKey(e)) { + originalEvent.preventDefault(); + originalEvent.stopPropagation(); + return; + } if (!isHandled) { // eslint-disable-next-line default-case @@ -700,6 +711,25 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo && !$target.is($masterDetail); } + private isWaitingForRowsRendering(eventArgs: KeyboardKeyDownEvent): boolean { + return this._rowsView.isWaitingForRowsLayout() + && !this._editingController?.isEditing() + && !this.isInsideMasterDetail($(eventArgs.originalEvent.target)); + } + + private isFocusedCellKey(eventArgs: KeyboardKeyDownEvent): boolean { + const { + keyName, key, ctrl, alt, originalEvent, + } = eventArgs; + const isViewSwitch = ctrl && (keyName === 'upArrow' || keyName === 'downArrow'); + const isCommandShortcut = isCommandKeyPressed(originalEvent) && (keyName === 'A' || keyName === 'F'); + const isFastEditingKey = key?.length === 1 + && !ctrl && !alt && !isCommandShortcut + && !!this._isFastEditingAllowed(); + + return !isViewSwitch && (FOCUSED_CELL_KEYS.includes(keyName) || isFastEditingKey); + } + private _upDownKeysHandler(eventArgs, isEditing) { const visibleRowIndex = this.getVisibleRowIndex(); const $row = this._focusedView && this._focusedView.getRow(visibleRowIndex); diff --git a/packages/devextreme/js/__internal/grids/grid_core/row_dragging/m_row_dragging.ts b/packages/devextreme/js/__internal/grids/grid_core/row_dragging/m_row_dragging.ts index 6c1fd72250cd..28de0625f716 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/row_dragging/m_row_dragging.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/row_dragging/m_row_dragging.ts @@ -150,7 +150,7 @@ const rowsView = (Base: ModuleType) => class RowsViewRowDraggingExtend } protected _renderCore(e) { - super._renderCore.apply(this, arguments as any); + const deferred = super._renderCore.apply(this, arguments as any); if (e && e.changeType === 'update' && e.repaintChangesOnly @@ -159,6 +159,9 @@ const rowsView = (Base: ModuleType) => class RowsViewRowDraggingExtend this._updateSortable(); }); } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return deferred; } private _updateSortable() { 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 new file mode 100644 index 000000000000..7d605345d194 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts @@ -0,0 +1,98 @@ +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 { + afterTest, beforeTest, createDataGrid, +} from '../../__tests__/__mock__/helpers/utils'; + +interface TemplateArgs { + model: { value: unknown }; + container: HTMLElement; + onRendered?: () => void; +} + +const ROWS = Array.from({ length: 6 }, (_, index) => ({ id: index, name: `Row ${index}` })); +const PAGE_SIZE = 3; +const FIRST_PAGE_ROW_KEYS = [0, 1, 2]; +const SECOND_PAGE_ROW_KEYS = [3, 4, 5]; + +const getRowKeys = ($container: dxElementWrapper): number[] => { + const container = $container.get(0) as HTMLElement; + const rows = container.querySelectorAll('.dx-datagrid-rowsview tr.dx-data-row'); + + return Array.from(rows, (row) => Number(row.cells[0].textContent)); +}; + +describe('RowsView renderCompleted with async templates', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const createGrid = async (isTemplateRenderedAsync: boolean) => { + const pendingTemplates: (() => void)[] = []; + const renderPendingTemplates = (): void => { + while (pendingTemplates.length) { + pendingTemplates.shift()?.(); + jest.runAllTimers(); + } + }; + + const grid = await createDataGrid({ + dataSource: ROWS, + paging: { pageSize: PAGE_SIZE }, + columns: ['id', { dataField: 'name', cellTemplate: 'cellTemplate' }], + templatesRenderAsynchronously: true, + integrationOptions: { + templates: { + cellTemplate: { + render({ model, container, onRendered }: TemplateArgs): void { + const renderTemplate = (): void => { + container.append(String(model.value)); + onRendered?.(); + }; + + if (isTemplateRenderedAsync) { + pendingTemplates.push(renderTemplate); + } else { + renderTemplate(); + } + }, + }, + }, + }, + } as DataGridProperties); + renderPendingTemplates(); + + const rowKeysOnRenderCompleted: number[][] = []; + grid.instance.getView('rowsView').renderCompleted.add(() => { + rowKeysOnRenderCompleted.push(getRowKeys(grid.$container)); + }); + + return { ...grid, renderPendingTemplates, rowKeysOnRenderCompleted }; + }; + + it('should fire renderCompleted only once the new rows waiting for async templates are in the DOM', async () => { + const { instance, renderPendingTemplates, rowKeysOnRenderCompleted } = await createGrid(true); + + const paging = instance.pageIndex(1); + jest.runAllTimers(); + await paging; + + expect(rowKeysOnRenderCompleted).toEqual([]); + + renderPendingTemplates(); + + expect(rowKeysOnRenderCompleted).toEqual([SECOND_PAGE_ROW_KEYS]); + }); + + it('should fire renderCompleted synchronously when the templates are rendered synchronously', async () => { + const { instance, rowKeysOnRenderCompleted } = await createGrid(false); + + instance.getView('rowsView').render(); + + expect(rowKeysOnRenderCompleted).toEqual([FIRST_PAGE_ROW_KEYS]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/utils.test.ts index 027749b1f1f0..22adeaa38558 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import { getMaxHorizontalScrollOffset } from '../utils'; +import { getMaxHorizontalScrollOffset, isRowElementVisible, isSameColumnLayout } from '../utils'; const createContainerMock = ( scrollWidth: number, @@ -30,3 +31,61 @@ describe('getMaxHorizontalScrollOffset', () => { expect(getMaxHorizontalScrollOffset(container)).toBe(1005); }); }); + +describe('isSameColumnLayout', () => { + const createColumns = ( + ...columns: Pick[] + ): Column[] => columns.map((column) => ({ ...column } as Column)); + + it('accepts new column objects at the same positions', () => { + const renderedColumns = createColumns({ command: 'select' }, { index: 0 }, { index: 1 }); + const columns = createColumns({ command: 'select' }, { index: 0 }, { index: 1 }); + + expect(isSameColumnLayout(renderedColumns, columns)).toBe(true); + }); + + it('accepts no columns', () => { + expect(isSameColumnLayout([], [])).toBe(true); + }); + + it('rejects a hidden column', () => { + const renderedColumns = createColumns({ index: 0 }, { index: 1 }, { index: 2 }); + const columns = createColumns({ index: 0 }, { index: 2 }); + + expect(isSameColumnLayout(renderedColumns, columns)).toBe(false); + }); + + it('rejects reordered columns', () => { + const renderedColumns = createColumns({ index: 0 }, { index: 1 }); + const columns = createColumns({ index: 1 }, { index: 0 }); + + expect(isSameColumnLayout(renderedColumns, columns)).toBe(false); + }); + + it('rejects a command column in place of a data column', () => { + const renderedColumns = createColumns({ index: 0 }, { index: 1 }); + const columns = createColumns({ command: 'adaptive' }, { index: 1 }); + + expect(isSameColumnLayout(renderedColumns, columns)).toBe(false); + }); +}); + +describe('isRowElementVisible', () => { + it('accepts a shown row', () => { + expect(isRowElementVisible(document.createElement('tr'))).toBe(true); + }); + + it('rejects a row hidden by the invisible class', () => { + const rowElement = document.createElement('tr'); + rowElement.classList.add('dx-state-invisible'); + + expect(isRowElementVisible(rowElement)).toBe(false); + }); + + it('rejects a row hidden by an inline style', () => { + const rowElement = document.createElement('tr'); + rowElement.style.display = 'none'; + + expect(isRowElementVisible(rowElement)).toBe(false); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_columns_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_columns_view.ts index eabc4afd644d..cfdbb031616c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_columns_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_columns_view.ts @@ -1143,6 +1143,10 @@ export class ColumnsView extends ColumnStateMixin(modules.View) { return this.option('templatesRenderAsynchronously') && this.option('renderAsync') === false; } + public isWaitingForAsyncTemplates(): boolean { + return !!this.needWaitAsyncTemplates() && this._templateDeferreds?.size > 0; + } + public waitAsyncTemplates(forceWaiting = false) { // @ts-expect-error const result = new Deferred(); 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 b5b668f7b4de..90bf4b56755c 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 @@ -3,6 +3,7 @@ import eventsEngine from '@js/common/core/events/core/events_engine'; import { removeEvent } from '@js/common/core/events/remove'; import messageLocalization from '@js/common/core/localization/message'; +import { data as elementData } from '@js/core/element_data'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import browser from '@js/core/utils/browser'; @@ -18,9 +19,12 @@ import { isDefined, isNumeric, isString } from '@js/core/utils/type'; import { getWindow, hasWindow } from '@js/core/utils/window'; import Scrollable from '@js/ui/scroll_view/ui.scrollable'; import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { ColumnsResizerViewController, } from '@ts/grids/grid_core/columns_resizing_reordering/m_columns_resizing_reordering'; +import type { ProcessedItem } from '@ts/grids/grid_core/data_controller/types'; +import { isSameItem } from '@ts/grids/grid_core/data_controller/utils/row_changes'; import { generateRowValues } from '@ts/grids/grid_core/data_controller/utils/row_values'; import type { FocusController } from '@ts/grids/grid_core/focus/m_focus'; import type { KeyboardNavigationController } from '@ts/grids/grid_core/keyboard_navigation/m_keyboard_navigation'; @@ -34,7 +38,9 @@ import gridCoreUtils from '../m_utils'; import { CLASSES } from '../sticky_columns/const'; import { ColumnsView } from './m_columns_view'; import type { RowsViewScrollEvent } from './types'; -import { getCellText, getMaxHorizontalScrollOffset } from './utils'; +import { + getCellText, getMaxHorizontalScrollOffset, isRowElementVisible, isSameColumnLayout, +} from './utils'; const ROWS_VIEW_CLASS = 'rowsview'; const CONTENT_CLASS = 'content'; @@ -422,6 +428,28 @@ export class RowsView extends ColumnsView { return this._findContentElement(); } + public isWaitingForRowsLayout(): boolean { + return this.isWaitingForAsyncTemplates() && !this.isRenderedLayoutCurrent(); + } + + private isRenderedLayoutCurrent(): boolean { + const items = this._dataController.items(); + const rowElements: HTMLElement[] = this._getRowElements().toArray(); + const renderedRows: (ProcessedItem & { columns: Column[] })[] = rowElements.map( + (rowElement) => elementData(rowElement, 'options'), + ); + + const isSameRows = renderedRows.length === items.length + && renderedRows.every((renderedRow, index) => isSameItem(renderedRow, items[index], true) + && isRowElementVisible(rowElements[index]) === (items[index].visible !== false)); + + if (!isSameRows || !renderedRows.length) { + return isSameRows; + } + + return isSameColumnLayout(renderedRows[0].columns, this._columnsController.getVisibleColumns()); + } + /** * @extended: editing_form_based, virtual_scrolling */ 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 9a2e10b6435a..984fbd58754f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts @@ -3,6 +3,8 @@ import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import { AI_COLUMN_NAME } from '../ai_column/const'; import gridCoreUtils from '../m_utils'; +const INVISIBLE_CLASS = 'dx-state-invisible'; + export const getCellText = ( column: Column, displayValue: unknown, @@ -15,3 +17,18 @@ export const getCellText = ( export const getMaxHorizontalScrollOffset = (container: HTMLElement | undefined): number => ( container ? Math.round(container.scrollWidth - container.clientWidth) : 0 ); + +// refresh() rebuilds the column objects, so they are compared by index and command, +// not by reference. +export const isSameColumnLayout = ( + renderedColumns: Column[], + columns: Column[], +): boolean => renderedColumns.length === columns.length + && renderedColumns.every((column, index) => ( + column.index === columns[index].index && column.command === columns[index].command + )); + +// Without jQuery the renderer hides rows with a class; with jQuery, with an inline style. +export const isRowElementVisible = (rowElement: HTMLElement): boolean => ( + rowElement.style.display !== 'none' && !rowElement.classList.contains(INVISIBLE_CLASS) +); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardController.tests.js index ea3dfe85b45d..786227fb4416 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardController.tests.js @@ -106,6 +106,9 @@ QUnit.module('Keyboard controller', { return this._views[name]; }, renderFocusState: commonUtils.noop, + isWaitingForRowsLayout: function() { + return false; + }, renderCompleted: $.Callbacks() }; };