diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts index 529353610025..242bdcc824c6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -34,6 +34,11 @@ export interface Coordinates { y: number; } +export interface SelectionRange { + selectionStart: number; + selectionEnd: number; +} + // todo: move to upper .d.ts type OptionsMethod = (() => TOptions) & ((options: TOptions) => void) diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index 7552acff84ba..ca64420e7f46 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -23,7 +23,7 @@ import sharedFiltering from '@js/ui/shared/filtering'; import { getGlobalFormatByDataType } from '@ts/core/global_format_config'; import { isNumeric } from '@ts/core/utils/m_type'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import type { ColumnPoint } from '@ts/grids/grid_core/m_types'; +import type { ColumnPoint, SelectionRange } from '@ts/grids/grid_core/m_types'; import { AI_COLUMN_NAME } from './ai_column/const'; import type DataSourceAdapter from './data_source_adapter/m_data_source_adapter'; @@ -504,22 +504,25 @@ export default { isDateType, - getSelectionRange(focusedElement) { + getSelectionRange(focusedElement): SelectionRange { try { if (focusedElement) { return { - selectionStart: focusedElement.selectionStart, - selectionEnd: focusedElement.selectionEnd, + selectionStart: isNumeric(focusedElement.selectionStart) ? focusedElement.selectionStart : -1, + selectionEnd: isNumeric(focusedElement.selectionEnd) ? focusedElement.selectionEnd : -1, }; } } catch (e) { /* empty */ } - return {}; + return { + selectionStart: -1, + selectionEnd: -1, + }; }, - setSelectionRange(focusedElement, selectionRange) { + setSelectionRange(focusedElement, selectionRange: SelectionRange): void { try { - if (focusedElement && focusedElement.setSelectionRange) { + if (focusedElement && focusedElement.setSelectionRange && selectionRange.selectionStart >= 0 && selectionRange.selectionEnd >= 0) { focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd); } } catch (e) { /* empty */ } diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/grid_view.normalize_widths_by_expand_columns.test.ts b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/grid_view.normalize_widths_by_expand_columns.test.ts new file mode 100644 index 000000000000..16b8b9c247de --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/grid_view.normalize_widths_by_expand_columns.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { Column } from '../../columns_controller/types'; +import { ResizingController } from '../m_grid_view'; + +type ColumnWidth = number | string | undefined; + +// NOTE: the method is private, so it is picked from the prototype to be tested in isolation. +const resizingControllerPrototype = ResizingController.prototype as unknown as { + normalizeWidthsByExpandColumns: ( + resultWidths: ColumnWidth[], + visibleColumns: Column[], + ) => void; +}; + +const normalizeWidthsByExpandColumns = ( + resultWidths: ColumnWidth[], + visibleColumns: Column[], +): ColumnWidth[] => { + resizingControllerPrototype.normalizeWidthsByExpandColumns(resultWidths, visibleColumns); + + return resultWidths; +}; + +const expandColumn = (): Column => ({ type: 'groupExpand', command: 'expand' } as Column); +const dataColumn = (dataField: string): Column => ({ dataField } as Column); + +describe('ResizingController.normalizeWidthsByExpandColumns', () => { + it('leaves the widths as is when there are no expand columns', () => { + const columns = [dataColumn('a'), dataColumn('b')]; + + expect(normalizeWidthsByExpandColumns([100, 200], columns)).toEqual([100, 200]); + }); + + it('leaves the widths as is when there is a single expand column', () => { + const columns = [expandColumn(), dataColumn('a')]; + + expect(normalizeWidthsByExpandColumns([30, 200], columns)).toEqual([30, 200]); + }); + + // NOTE: all groupExpand columns share a single column id (command:expand), so the width + // of the last one is the value that _setVisibleWidths actually applies to all of them. + it('applies the width of the LAST expand column to every expand column', () => { + const columns = [expandColumn(), expandColumn(), dataColumn('a')]; + + expect(normalizeWidthsByExpandColumns([21, 30, 200], columns)).toEqual([30, 30, 200]); + }); + + it('normalizes expand columns that are not adjacent to each other', () => { + const columns = [ + dataColumn('a'), + expandColumn(), + dataColumn('b'), + expandColumn(), + dataColumn('c'), + ]; + + expect(normalizeWidthsByExpandColumns([100, 21, 200, 30, 300], columns)) + .toEqual([100, 30, 200, 30, 300]); + }); + + it('ignores the detailExpand column', () => { + const columns = [ + { type: 'detailExpand', command: 'expand' } as Column, + expandColumn(), + expandColumn(), + ]; + + expect(normalizeWidthsByExpandColumns([15, 21, 30], columns)).toEqual([15, 30, 30]); + }); + + // NOTE: a falsy width means the column could not be measured (e.g. the grid is hidden). + it.each([ + ['zero', 0], + ['undefined', undefined], + ])('keeps the measured widths when the last expand column width is %s', (_, width) => { + const columns = [expandColumn(), expandColumn(), dataColumn('a')]; + + expect(normalizeWidthsByExpandColumns([21, width, 200], columns)).toEqual([21, width, 200]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts index 259ff155673f..c30560361fd2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts @@ -21,13 +21,17 @@ import { A11yStatusContainerComponent } from '@ts/grids/grid_core/views/a11y_sta import type { FooterView } from '../../data_grid/summary/m_summary'; import type { AdaptiveColumnsController } from '../adaptivity/m_adaptivity'; import type { ColumnHeadersView } from '../column_headers/m_column_headers'; +import { GROUP_COMMAND_COLUMN_NAME } from '../columns_controller/const'; import type { ColumnsController } from '../columns_controller/m_columns_controller'; +import type { Column } from '../columns_controller/types'; import type { DataController } from '../data_controller/data_controller'; import type { DataChange } from '../data_controller/types'; import type { DataSourceController } from '../data_source/data_source_controller'; import modules from '../m_modules'; +import type { SelectionRange } from '../m_types'; import gridCoreUtils from '../m_utils'; import type { RowsView } from './m_rows_view'; +import type { ColumnWidth } from './types'; const BORDERS_CLASS = 'borders'; const TABLE_FIXED_CLASS = 'table-fixed'; @@ -81,7 +85,7 @@ const calculateFreeWidthWithCurrentMinWidth = function (that, columnIndex, curre return calculateFreeWidth(that, widths.map((width, index) => (index === columnIndex ? currentMinWidth : width))); }; -const restoreFocus = function (focusedElement, selectionRange) { +const restoreFocus = (focusedElement: Element, selectionRange: SelectionRange): void => { accessibility.hiddenFocus(focusedElement, true); gridCoreUtils.setSelectionRange(focusedElement, selectionRange); }; @@ -105,8 +109,6 @@ export class ResizingController extends modules.ViewController { private _prevContentMinHeight: any; - private _maxWidth: any; - private _hasWidth: any; private _hasHeight: any; @@ -127,6 +129,8 @@ export class ResizingController extends modules.ViewController { public resizeCompleted!: Callback; + private isMaxWidthSet = false; + protected callbackNames() { return ['resizeCompleted']; } @@ -345,85 +349,73 @@ export class ResizingController extends modules.ViewController { } } - private _synchronizeColumns() { - const columnsController = this._columnsController; - const visibleColumns = columnsController.getVisibleColumns(); - const columnAutoWidth = this.option('columnAutoWidth'); - const hasUndefinedColumnWidth = visibleColumns.some((column) => !isDefined(column.width)); - let needBestFit = this._needBestFit(); - let hasMinWidth = false; - let resetBestFitMode; - let isColumnWidthsCorrected = false; - let resultWidths: any[] = []; - let focusedElement; - let selectionRange; + private setMaxWidth(value: number): void { + this.isMaxWidthSet = true; + this.component.$element().css('maxWidth', value); + } - const normalizeWidthsByExpandColumns = function () { - let expandColumnWidth; + private _clearMaxWidth(): void { + if (!this.isMaxWidthSet) { + return; + } - each(visibleColumns, (index, column) => { - if (column.type === 'groupExpand') { - expandColumnWidth = resultWidths[index]; - } - }); + this.isMaxWidthSet = false; - each(visibleColumns, (index, column) => { - if (column.type === 'groupExpand' && expandColumnWidth) { - resultWidths[index] = expandColumnWidth; - } - }); - }; + const element = this.component.$element().get(0) as HTMLElement | undefined; - !needBestFit && each(visibleColumns, (index, column) => { - if (column.width === 'auto') { - needBestFit = true; - return false; - } - return undefined; - }); + if (element) { + element.style.maxWidth = ''; + } + } - each(visibleColumns, (index, column) => { - if (column.minWidth) { - hasMinWidth = true; - return false; - } - return undefined; - }); + private enableTemporaryBestFitMode(): () => void { + const $element = this.component.$element(); + const focusedElement = domAdapter.getActiveElement($element.get(0) as HTMLElement | null); + const selectionRange = gridCoreUtils.getSelectionRange(focusedElement); - this._toggleContentMinHeight(this._hasHeight); // T1047239, T1270354 + this._toggleBestFitMode(true); - this._setVisibleWidths(visibleColumns, []); + return (): void => { + this._toggleBestFitMode(false); - const $element = this.component.$element(); + if (focusedElement && focusedElement !== domAdapter.getActiveElement()) { + const isFocusOutsideWindow = getBoundingRect(focusedElement).bottom < 0; - if (needBestFit) { - // @ts-expect-error - focusedElement = domAdapter.getActiveElement($element.get(0)); - selectionRange = gridCoreUtils.getSelectionRange(focusedElement); - this._toggleBestFitMode(true); - resetBestFitMode = true; - } + if (!isFocusOutsideWindow) { + restoreFocus(focusedElement, selectionRange); + } + } + }; + } - if ($element && $element.get(0) && this._maxWidth) { - delete this._maxWidth; - $element[0].style.maxWidth = ''; - } + private synchronizeColumns(): void { + const columnsController = this._columnsController; + const visibleColumns = columnsController.getVisibleColumns(); + const columnAutoWidth = this.option('columnAutoWidth') as boolean; + const hasUndefinedColumnWidth = visibleColumns.some((column) => !isDefined(column.width)); + const needBestFit = this._needBestFit() || visibleColumns.some((column) => column.width === 'auto'); + const hasMinWidth = visibleColumns.some((column) => !!column.minWidth); + + this._toggleContentMinHeight(this._hasHeight); // T1047239, T1270354 + this._setVisibleWidths(visibleColumns, []); + const restoreAfterBestFitMode = needBestFit && this.enableTemporaryBestFitMode(); + this._clearMaxWidth(); // eslint-disable-next-line @typescript-eslint/no-floating-promises deferUpdate(() => { - if (needBestFit) { + let resultWidths: ColumnWidth[] = []; + + if (needBestFit || hasMinWidth) { resultWidths = this._getBestFitWidths(); + } - each(visibleColumns, (index, column) => { + each(visibleColumns, (index, column) => { + if (needBestFit) { const columnId = columnsController.getColumnId(column); columnsController.columnOption(columnId, 'bestFitWidth', resultWidths[index], true); - }); - } else if (hasMinWidth) { - resultWidths = this._getBestFitWidths(); - } + } - each(visibleColumns, function (index) { - const { width } = this; + const { width } = column; if (width !== 'auto') { if (isDefined(width)) { resultWidths[index] = isNumeric(width) || isPixelWidth(width) ? parseFloat(width) : width; @@ -433,21 +425,14 @@ export class ResizingController extends modules.ViewController { } }); - if (resetBestFitMode) { - this._toggleBestFitMode(false); - resetBestFitMode = false; - if (focusedElement && focusedElement !== domAdapter.getActiveElement()) { - const isFocusOutsideWindow = getBoundingRect(focusedElement).bottom < 0; - if (!isFocusOutsideWindow) { - restoreFocus(focusedElement, selectionRange); - } - } + if (restoreAfterBestFitMode) { + restoreAfterBestFitMode(); } - isColumnWidthsCorrected = this._correctColumnWidths(resultWidths, visibleColumns); + const isColumnWidthsCorrected = this._correctColumnWidths(resultWidths, visibleColumns); if (columnAutoWidth) { - normalizeWidthsByExpandColumns(); + this.normalizeWidthsByExpandColumns(resultWidths, visibleColumns); if (this._needStretch()) { this._processStretch(resultWidths, visibleColumns); } @@ -485,6 +470,34 @@ export class ResizingController extends modules.ViewController { return freeWidth / columnCountWithoutWidth; } + private normalizeWidthsByExpandColumns( + resultWidths: ColumnWidth[], + visibleColumns: Column[], + ): void { + const isExpandColumn = (column: Column): boolean => column.type === GROUP_COMMAND_COLUMN_NAME; + + const lastExpandColumnIndex = visibleColumns.reduce( + (result, column, index) => (isExpandColumn(column) ? index : result), + -1, + ); + + if (lastExpandColumnIndex < 0) { + return; + } + + const expandColumnWidth = resultWidths[lastExpandColumnIndex]; + + if (!expandColumnWidth) { + return; + } + + visibleColumns.forEach((column, index) => { + if (isExpandColumn(column)) { + resultWidths[index] = expandColumnWidth; + } + }); + } + /** * @extended: adaptivity */ @@ -494,7 +507,6 @@ export class ResizingController extends modules.ViewController { let hasPercentWidth = false; let hasAutoWidth = false; let isColumnWidthsCorrected = false; - const $element = that.component.$element(); const hasWidth = that._hasWidth; for (i = 0; i < visibleColumns.length; i++) { @@ -547,9 +559,7 @@ export class ResizingController extends modules.ViewController { if (hasWidth === false && !hasPercentWidth) { const borderWidth = gridCoreUtils.getComponentBorderWidth(this, $rowsViewElement); - that._maxWidth = totalWidth + scrollbarWidth + borderWidth; - - $element.css('maxWidth', that._maxWidth); + that.setMaxWidth(totalWidth + scrollbarWidth + borderWidth); } } } @@ -723,7 +733,7 @@ export class ResizingController extends modules.ViewController { when(prevResult).always(() => { deferRender(() => { if (that._dataController.isLoaded()) { - that._synchronizeColumns(); + that.synchronizeColumns(); } // IE11 that._resetGroupElementHeight(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/types.ts b/packages/devextreme/js/__internal/grids/grid_core/views/types.ts index a2f0306d5705..485c13a4f5ed 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/types.ts @@ -6,3 +6,5 @@ export type RowsViewScrollEvent = Partial> & { scrollOffset: { top: number; left: number }; forceUpdateScrollPosition?: boolean; }; + +export type ColumnWidth = number | string | undefined; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataGrid.resizing.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataGrid.resizing.tests.js index 1aac2811455c..5ac896cb34b5 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataGrid.resizing.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataGrid.resizing.tests.js @@ -1230,13 +1230,13 @@ QUnit.module('API Methods', baseModuleConfig, () => { dataSource: [{ field1: 1, field2: 2, field3: 3 }] }); - sinon.spy(dataGrid.getController('resizing'), '_synchronizeColumns'); + sinon.spy(dataGrid.getController('resizing'), 'synchronizeColumns'); // act resizeCallbacks.fire(); // assert - assert.equal(dataGrid.getController('resizing')._synchronizeColumns.callCount, 0, 'synchronizeColumns is not called'); + assert.equal(dataGrid.getController('resizing').synchronizeColumns.callCount, 0, 'synchronizeColumns is not called'); // act @@ -1244,7 +1244,7 @@ QUnit.module('API Methods', baseModuleConfig, () => { resizeCallbacks.fire(); // assert - assert.equal(dataGrid.getController('resizing')._synchronizeColumns.callCount, 1, 'synchronizeColumns is called'); + assert.equal(dataGrid.getController('resizing').synchronizeColumns.callCount, 1, 'synchronizeColumns is called'); }); // T372519 @@ -1264,7 +1264,7 @@ QUnit.module('API Methods', baseModuleConfig, () => { const rowsViewHeight = $('#dataGrid .dx-datagrid-rowsview').height(); - sinon.spy(dataGrid.getController('resizing'), '_synchronizeColumns'); + sinon.spy(dataGrid.getController('resizing'), 'synchronizeColumns'); // act $('#qunit-fixture').hide(); @@ -1272,7 +1272,7 @@ QUnit.module('API Methods', baseModuleConfig, () => { $('#qunit-fixture').show(); // assert - assert.equal(dataGrid.getController('resizing')._synchronizeColumns.callCount, 0, 'synchronizeColumns is not called'); + assert.equal(dataGrid.getController('resizing').synchronizeColumns.callCount, 0, 'synchronizeColumns is not called'); assert.equal($('#dataGrid .dx-datagrid-rowsview').height(), rowsViewHeight, 'rowsView height is not changed'); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filtering.integration.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filtering.integration.tests.js index 76d6fe2ed6e4..181377bcf6f9 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filtering.integration.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filtering.integration.tests.js @@ -194,7 +194,7 @@ QUnit.module('Initialization', baseModuleConfig, () => { assert.equal(dataGrid.getVisibleRows().length, 1, 'rows are filtered'); }); - QUnit.testInActiveWindow('Filter row editor should have focus after _synchronizeColumns (T638737)', function(assert) { + QUnit.testInActiveWindow('Filter row editor should have focus after synchronizeColumns (T638737)', function(assert) { $('#qunit-fixture').css('position', 'static'); // arrange, act const dataGrid = createDataGrid({