diff --git a/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/column_chooser.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/column_chooser.ts new file mode 100644 index 000000000000..c2d7657a7083 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/column_chooser.ts @@ -0,0 +1,43 @@ +import { TreeViewModel } from '@ts/ui/__tests__/__mock__/model/tree_view'; + +const CLASSES = { + columnChooser: 'dx-datagrid-column-chooser', + columnChooserList: 'dx-datagrid-column-chooser-list', + popupWrapper: 'dx-popup-wrapper', +}; + +export class ColumnChooserModel { + constructor(protected readonly root: HTMLElement) {} + + private getPopupWrapper(): HTMLElement | null { + return document.body.querySelector(`.${CLASSES.popupWrapper}.${CLASSES.columnChooser}`); + } + + private getOverlay(): HTMLElement | null { + const wrapper = this.getPopupWrapper(); + return wrapper?.querySelector('.dx-overlay-content') ?? null; + } + + private getTreeView(): TreeViewModel | null { + const overlay = this.getOverlay(); + if (!overlay) return null; + + const treeViewElement = overlay.querySelector(`.${CLASSES.columnChooserList}`) as HTMLElement; + return treeViewElement ? new TreeViewModel(treeViewElement) : null; + } + + public isVisible(): boolean { + return this.getOverlay() !== null; + } + + public searchColumn(text: string): void { + const treeView = this.getTreeView(); + treeView?.setSearchValue(text); + } + + public toggleColumn(columnText: string): void { + const treeView = this.getTreeView(); + const checkBox = treeView?.getCheckboxByText(columnText); + checkBox?.toggle(); + } +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/grid_core.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/grid_core.ts index b949ac1d3ee0..0d93490374ae 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/grid_core.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/grid_core.ts @@ -10,6 +10,7 @@ import { AIPromptEditorModel } from './ai_prompt_editor'; import { AIHeaderCellModel } from './cell/ai_header_cell'; import { DataCellModel } from './cell/data_cell'; import { HeaderCellModel } from './cell/header_cell'; +import { ColumnChooserModel } from './column_chooser'; import { EditFormModel } from './edit_form'; import { DataRowModel } from './row/data_row'; @@ -134,5 +135,9 @@ export abstract class GridCoreModel { return new EditFormModel(this.root.querySelector(`.${this.addWidgetPrefix(SELECTORS.editForm)}`)); } + public getColumnChooser(): ColumnChooserModel { + return new ColumnChooserModel(this.root); + } + public abstract getInstance(): TInstance; } diff --git a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/__tests__/column_chooser.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/__tests__/column_chooser.integration.test.ts new file mode 100644 index 000000000000..3b2dd3da8f7e --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/__tests__/column_chooser.integration.test.ts @@ -0,0 +1,307 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import DataGrid from '@js/ui/data_grid'; +import errors from '@js/ui/widget/ui.errors'; +import { DataGridModel } from '@ts/grids/data_grid/__tests__/__mock__/model/data_grid'; + +const SELECTORS = { + gridContainer: '#gridContainer', +}; + +const GRID_CONTAINER_ID = 'gridContainer'; + +const createDataGrid = async ( + options: DataGridProperties = {}, +): Promise<{ + $container: dxElementWrapper; + component: DataGridModel; + instance: DataGrid; +}> => new Promise((resolve) => { + const $container = $('
') + .attr('id', GRID_CONTAINER_ID) + .appendTo(document.body); + + const dataGridOptions: DataGridProperties = { + keyExpr: 'id', + ...options, + }; + + const instance = new DataGrid($container.get(0) as HTMLDivElement, dataGridOptions); + const component = new DataGridModel($container.get(0) as HTMLElement); + + jest.runAllTimers(); + + resolve({ + $container, + component, + instance, + }); +}); + +const beforeTest = (): void => { + jest.useFakeTimers(); + jest.spyOn(errors, 'log').mockImplementation(jest.fn()); + jest.spyOn(errors, 'Error').mockImplementation(() => ({})); +}; + +const afterTest = (): void => { + const $container = $(SELECTORS.gridContainer); + const dataGrid = ($container as any).dxDataGrid('instance') as DataGrid; + + dataGrid.dispose(); + $container.remove(); + jest.clearAllMocks(); + jest.useRealTimers(); +}; + +describe('Bugs', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('T1311329 - DataGrid - Column chooser hides a banded column on using search and recursive selection', () => { + it('should not hide banded column when using search (two levels)', async () => { + const { instance, component } = await createDataGrid({ + dataSource: [ + { + id: 1, + name: 'Name 1', + value: 10, + phone: 'Banded 1', + email: 'Banded 2', + skype: 'Banded 3', + }, + ], + columnChooser: { + enabled: true, + search: { + enabled: true, + }, + mode: 'select', + selection: { + recursive: true, + selectByClick: true, + allowSelectAll: true, + }, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + caption: 'Contacts', + columns: [ + { + dataField: 'phone', + visible: false, + }, + { + dataField: 'email', + }, + { + dataField: 'skype', + }, + ], + }, + ], + }); + + let visibleColumnsLevel0 = instance.getVisibleColumns(0); + let visibleColumnsLevel1 = instance.getVisibleColumns(1); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'Contacts')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'phone')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'email')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'skype')).toBeDefined(); + expect(visibleColumnsLevel0.find((col) => col.dataField === 'name')).toBeDefined(); + + instance.showColumnChooser(); + jest.runAllTimers(); + + const columnChooser = component.getColumnChooser(); + expect(columnChooser.isVisible()).toBe(true); + + columnChooser.searchColumn('n'); + jest.runAllTimers(); + + columnChooser.toggleColumn('Name'); + jest.runAllTimers(); + + visibleColumnsLevel0 = instance.getVisibleColumns(0); + visibleColumnsLevel1 = instance.getVisibleColumns(1); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'Contacts')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'phone')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'email')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'skype')).toBeDefined(); + expect(visibleColumnsLevel0.find((col) => col.dataField === 'name')).toBeUndefined(); + }); + + it('should not hide banded column when using search (three levels)', async () => { + const { instance, component } = await createDataGrid({ + dataSource: [], + columnChooser: { + enabled: true, + search: { + enabled: true, + }, + mode: 'select', + selection: { + recursive: true, + selectByClick: true, + allowSelectAll: true, + }, + }, + columns: [ + { + caption: 'band_level1', + columns: [ + { + caption: 'band_level2', + columns: [ + { + dataField: 'data1_level3', + visible: false, + }, + { + dataField: 'data2_level3', + }, + ], + }, + { + dataField: 'data1_level2', + }, + { + dataField: 'data2_level2', + }, + ], + }, + { + dataField: 'data1_level1', + }, + ], + }); + + let visibleColumnsLevel0 = instance.getVisibleColumns(0); + let visibleColumnsLevel1 = instance.getVisibleColumns(1); + let visibleColumnsLevel2 = instance.getVisibleColumns(2); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'band_level1')).toBeDefined(); + expect(visibleColumnsLevel0.find((col) => col.dataField === 'data1_level1')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'data1_level2')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'data2_level2')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.caption === 'band_level2')).toBeDefined(); + expect(visibleColumnsLevel2.find((col) => col.dataField === 'data1_level3')).toBeUndefined(); + expect(visibleColumnsLevel2.find((col) => col.dataField === 'data2_level3')).toBeDefined(); + + instance.showColumnChooser(); + jest.runAllTimers(); + + const columnChooser = component.getColumnChooser(); + expect(columnChooser.isVisible()).toBe(true); + + columnChooser.searchColumn('1'); + jest.runAllTimers(); + + columnChooser.toggleColumn('Data 1 level 1'); + jest.runAllTimers(); + + visibleColumnsLevel0 = instance.getVisibleColumns(0); + visibleColumnsLevel1 = instance.getVisibleColumns(1); + visibleColumnsLevel2 = instance.getVisibleColumns(2); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'band_level1')).toBeDefined(); + expect(visibleColumnsLevel0.find((col) => col.dataField === 'data1_level1')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'data1_level2')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'data2_level2')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.caption === 'band_level2')).toBeDefined(); + expect(visibleColumnsLevel2.find((col) => col.dataField === 'data1_level3')).toBeUndefined(); + expect(visibleColumnsLevel2.find((col) => col.dataField === 'data2_level3')).toBeDefined(); + }); + + it('should hide banded column by click', async () => { + const { instance, component } = await createDataGrid({ + dataSource: [ + { + id: 1, + name: 'Name 1', + value: 10, + phone: 'Banded 1', + email: 'Banded 2', + skype: 'Banded 3', + }, + ], + columnChooser: { + enabled: true, + search: { + enabled: true, + }, + mode: 'select', + selection: { + recursive: true, + selectByClick: true, + allowSelectAll: true, + }, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + caption: 'Contacts', + columns: [ + { + dataField: 'phone', + visible: false, + }, + { + dataField: 'email', + }, + { + dataField: 'skype', + }, + ], + }, + ], + }); + let visibleColumnsLevel0 = instance.getVisibleColumns(0); + let visibleColumnsLevel1 = instance.getVisibleColumns(1); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'Contacts')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'phone')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'email')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'skype')).toBeDefined(); + + instance.showColumnChooser(); + jest.runAllTimers(); + + const columnChooser = component.getColumnChooser(); + expect(columnChooser.isVisible()).toBe(true); + + columnChooser.toggleColumn('Contacts'); + jest.runAllTimers(); + + visibleColumnsLevel0 = instance.getVisibleColumns(0); + visibleColumnsLevel1 = instance.getVisibleColumns(1); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'Contacts')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'phone')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'email')).toBeDefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'skype')).toBeDefined(); + + columnChooser.toggleColumn('Contacts'); + jest.runAllTimers(); + + visibleColumnsLevel0 = instance.getVisibleColumns(0); + visibleColumnsLevel1 = instance.getVisibleColumns(1); + + expect(visibleColumnsLevel0.find((col) => col.caption === 'Contacts')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'phone')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'email')).toBeUndefined(); + expect(visibleColumnsLevel1.find((col) => col.dataField === 'skype')).toBeUndefined(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts index 96bfc370b10a..ad20ad949699 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts @@ -9,6 +9,7 @@ import { isDefined } from '@js/core/utils/type'; import Button from '@js/ui/button'; import type { Properties as PopupProperties } from '@js/ui/popup'; import Popup from '@js/ui/popup/ui.popup'; +import type { Item } from '@js/ui/tree_view'; import TreeView from '@js/ui/tree_view'; import type { RowsView } from '@ts/grids/grid_core/views/m_rows_view'; @@ -31,6 +32,13 @@ const COLUMN_CHOOSER_ITEM_CLASS = 'dx-column-chooser-item'; const COLUMN_OPTIONS_USED_IN_ITEMS = ['showInColumnChooser', 'caption', 'allowHiding', 'visible', 'cssClass', 'ownerBand']; +type NodeInternal = Item & { + itemData: { + id: number; + }; + children: NodeInternal[]; +}; + const processItems = function (that: ColumnChooserView, chooserColumns) { const items: any = []; const isSelectMode = that.isSelectMode(); @@ -288,23 +296,55 @@ export class ColumnChooserView extends ColumnsView { }; } - private _prepareSelectModeConfig() { - const that = this; - const selectionOptions = this.option('columnChooser.selection') ?? {}; + private _getBandColumnVisibility(columnIndex: number): boolean { + const childColumns = this._columnsController.getChildrenByBandColumn(columnIndex, true); + return !!childColumns.some((column) => !!column.visible); + } - const getFlatNodes = (nodes) => { - const addNodesToArray = (nodes, flatNodesArray) => nodes.reduce((result, node) => { - result.push(node); + private _getColumnVisibility( + columnIndex: number, + isNodeSelected: boolean | undefined, + ): boolean | undefined { + const column = this._columnsController.columnOption(columnIndex); + const selectionOptions = this.option('columnChooser.selection'); + const recursive = selectionOptions?.recursive; - if (node.children.length) { - addNodesToArray(node.children, result); - } + if (recursive && column?.hasColumns) { + return this._getBandColumnVisibility(columnIndex); + } - return result; - }, flatNodesArray); + return isNodeSelected; + } - return addNodesToArray(nodes, []); - }; + private _updateColumnVisibility(nodes: NodeInternal[]): void { + nodes.forEach((node) => { + const columnIndex = node.itemData.id; + const isVisible = this._getColumnVisibility(columnIndex, node.selected); + + this._columnsController.columnOption(columnIndex, 'visible', isVisible); + }); + } + + private _getSortedFlatNodes(nodes: NodeInternal[]): NodeInternal[] { + const getNodeLevelPairsRecursive = ( + sourceNodes: NodeInternal[], + flatNodesArray: NodeInternal[], + ): NodeInternal[] => sourceNodes.reduce((result, node) => { + result.push(node); + + if (node.children.length) { + getNodeLevelPairsRecursive(node.children, result); + } + + return result; + }, flatNodesArray); + + // Band columns should be updated after regular columns + return getNodeLevelPairsRecursive(nodes, []).reverse(); + } + + private _prepareSelectModeConfig() { + const selectionOptions = this.option('columnChooser.selection') ?? {}; const updateSelection = (e, nodes) => { nodes @@ -312,14 +352,6 @@ export class ColumnChooserView extends ColumnsView { .forEach((node) => e.component.selectItem(node.key)); }; - const updateColumnVisibility = (nodes) => { - nodes.forEach((node) => { - const columnIndex = node.itemData.id; - const isVisible = node.selected !== false; - that._columnsController.columnOption(columnIndex, 'visible', isVisible); - }); - }; - let isUpdatingSelection = false; const selectionChangedHandler = (e) => { @@ -327,7 +359,7 @@ export class ColumnChooserView extends ColumnsView { return; } - const nodes = getFlatNodes(e.component.getNodes()); + const nodes = this._getSortedFlatNodes(e.component.getNodes()); e.component.beginUpdate(); isUpdatingSelection = true; @@ -337,12 +369,12 @@ export class ColumnChooserView extends ColumnsView { e.component.endUpdate(); isUpdatingSelection = false; - that.component.beginUpdate(); + this.component.beginUpdate(); this._isUpdatingColumnVisibility = true; - updateColumnVisibility(nodes); + this._updateColumnVisibility(nodes); - that.component.endUpdate(); + this.component.endUpdate(); this._isUpdatingColumnVisibility = false; }; diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/checkbox.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/checkbox.ts new file mode 100644 index 000000000000..edc859b76028 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/checkbox.ts @@ -0,0 +1,13 @@ +import CheckBox from '@js/ui/check_box'; + +export class CheckBoxModel { + constructor(protected readonly root: HTMLElement) {} + + public getInstance(): CheckBox { + return CheckBox.getInstance(this.root) as CheckBox; + } + + public toggle(): void { + this.root.click(); + } +} diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/textbox.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/textbox.ts new file mode 100644 index 000000000000..5f52c040b63b --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/textbox.ts @@ -0,0 +1,13 @@ +import TextBox from '@js/ui/text_box'; + +export class TextBoxModel { + constructor(protected readonly root: HTMLElement) {} + + public getInstance(): TextBox { + return TextBox.getInstance(this.root) as TextBox; + } + + public setValue(value: string): void { + this.getInstance()?.option('value', value); + } +} diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tree_view.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tree_view.ts new file mode 100644 index 000000000000..a3bc104ee225 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tree_view.ts @@ -0,0 +1,52 @@ +import TreeView from '@js/ui/tree_view'; + +import { CheckBoxModel } from './checkbox'; +import { TextBoxModel } from './textbox'; + +const CLASSES = { + treeView: 'dx-treeview', + searchBox: 'dx-treeview-search', + node: 'dx-treeview-node', + item: 'dx-treeview-item', + checkbox: 'dx-checkbox', +}; + +export class TreeViewModel { + constructor(protected readonly root: HTMLElement) {} + + public getInstance(): TreeView { + return TreeView.getInstance(this.root) as TreeView; + } + + private getSearchBox(): TextBoxModel { + return new TextBoxModel(this.root?.querySelector(`.${CLASSES.searchBox}`) as HTMLElement); + } + + public setSearchValue(value: string): void { + const searchBox = this.getSearchBox(); + searchBox.setValue(value); + } + + private getNodes(): NodeListOf | null { + return this.root?.querySelectorAll(`.${CLASSES.node}`) ?? null; + } + + private getNodeByText(text: string): HTMLElement | null { + const nodes = this.getNodes(); + if (!nodes) return null; + + const foundNode = Array.from(nodes).find((node) => { + const itemElement = node.querySelector(`.${CLASSES.item}`); + const nodeText = itemElement?.textContent; + return nodeText?.includes(text); + }) ?? null; + + return foundNode; + } + + public getCheckboxByText(text: string): CheckBoxModel | null { + const node = this.getNodeByText(text); + const checkboxElement = node?.querySelector(`.${CLASSES.checkbox}`) as HTMLElement; + return checkboxElement ? new CheckBoxModel(checkboxElement) : null; + } +}