diff --git a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts index 80d7039edbd1..4b96b5c460e2 100644 --- a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts +++ b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts @@ -26,6 +26,8 @@ type SortOption = { desc?: boolean; } | string; +export type IndirectSelectionMode = 'all' | 'skipDisabled'; + interface LangParams { locale?: string; collator?: Intl.Collator; @@ -51,6 +53,8 @@ export interface DataAdapterOptions { onNodeChanged: (node: InternalNode) => void; searchMode: SearchMode; + + indirectSelectionMode: IndirectSelectionMode; } SearchBoxController.setEditorClass(TextBox); @@ -69,8 +73,11 @@ class DataAdapter { dataConverter: new HierarchicalDataConverter(), onNodeChanged: noop, sort: null, + indirectSelectionMode: 'all', }; + _disabledNodesKeys: ItemKey[] = []; + _selectedNodesKeys: ItemKey[] = []; _expandedNodesKeys: ItemKey[] = []; @@ -110,10 +117,15 @@ class DataAdapter { this.options.dataConverter._dataStructure = this._dataStructure; + this._updateDisabled(); this._updateSelection(); this._updateExpansion(); } + _updateDisabled(): void { + this._disabledNodesKeys = this._updateNodesKeysArray(DISABLED); + } + _updateSelection(): void { if (this.options.recursiveSelection) { this._setChildrenSelection(); @@ -144,6 +156,9 @@ class DataAdapter { } if (node.internalFields[property]) { + if (property === DISABLED) { + array.push(node.internalFields.key); + } if (property === EXPANDED || this.options.multipleSelection) { array.push(node.internalFields.key); } else { @@ -162,8 +177,12 @@ class DataAdapter { return this.options.multipleSelection ? this.getData() : this.getFullData(); } - _isNodeVisible(node: InternalNode): boolean { - return node.internalFields.item.visible !== false; + _isNodeVisible(node?: InternalNode): boolean { + return node?.internalFields.item.visible !== false; + } + + _isNodeDisabled(node?: InternalNode): boolean { + return node?.internalFields.item.disabled === true; } _getByKey(data: (InternalNode | null)[], key: ItemKey): InternalNode | null { @@ -193,6 +212,9 @@ class DataAdapter { if (parent && node.internalFields.parentKey !== this.options.rootValue) { this._iterateParents(node, (parentNode: InternalNode): void => { + if (this.options.indirectSelectionMode === 'skipDisabled' && this._isNodeDisabled(parentNode)) { + return; + } const newParentState = this._calculateSelectedState(parentNode); this._setFieldState(parentNode, SELECTED, newParentState); }); @@ -228,6 +250,10 @@ class DataAdapter { return; } + if (this.options.indirectSelectionMode === 'skipDisabled' && this._isNodeDisabled(node)) { + return; + } + const nodeKey = node.internalFields.key; const keys = processedKeys ?? []; if (nodeKey !== undefined && !keys.includes(nodeKey)) { @@ -273,15 +299,21 @@ class DataAdapter { const itemsCount = node.internalFields.childrenKeys.length; let selectedItemsCount = 0; let invisibleItemsCount = 0; + let disabledItemsCount = 0; let result: boolean | undefined = false; + const isSkipDisabled = this.options.indirectSelectionMode === 'skipDisabled'; + for (let i = 0; i <= itemsCount - 1; i += 1) { const childNode = this.getNodeByKey(node.internalFields.childrenKeys[i]); const isChildInvisible = childNode?.internalFields.item.visible === false; + const isChildDisabled = childNode?.internalFields.item.disabled === true; const childState = childNode?.internalFields.selected; if (isChildInvisible) { invisibleItemsCount += 1; + } else if (isChildDisabled && isSkipDisabled) { + disabledItemsCount += 1; } else if (childState) { selectedItemsCount += 1; } else if (childState === undefined) { @@ -289,8 +321,18 @@ class DataAdapter { } } + let subtractedItemsCount = invisibleItemsCount; + + if (isSkipDisabled) { + subtractedItemsCount += disabledItemsCount; + } + + if (itemsCount === subtractedItemsCount) { + return node.internalFields.selected; + } + if (selectedItemsCount) { - result = selectedItemsCount === itemsCount - invisibleItemsCount ? true : undefined; + result = selectedItemsCount === itemsCount - subtractedItemsCount ? true : undefined; } return result; @@ -298,7 +340,16 @@ class DataAdapter { _toggleChildrenSelection(node: InternalNode, state: boolean): void { this._iterateChildren(node, true, (child) => { - if (child && this._isNodeVisible(child)) { + if (!child) { + return; + } + + if (this.options.indirectSelectionMode === 'all' && this._isNodeVisible(child)) { + this._setFieldState(child, SELECTED, state); + return; + } + + if (!this._isNodeDisabled(child)) { this._setFieldState(child, SELECTED, state); } }); @@ -368,10 +419,16 @@ class DataAdapter { _updateFields(): void { this.options.dataConverter.updateChildrenKeys(); + + this._updateDisabled(); this._updateSelection(); this._updateExpansion(); } + getDisabledNodesKeys(): ItemKey[] { + return this._disabledNodesKeys; + } + getSelectedNodesKeys(): ItemKey[] { return this._selectedNodesKeys; } @@ -432,6 +489,10 @@ class DataAdapter { return this.options.dataConverter.getItemsCount(); } + _getDisabledItemsCount(): number { + return this.options.dataConverter.getDisabledItemsCount(); + } + getVisibleItemsCount(): number { return this.options.dataConverter.getVisibleItemsCount(); } @@ -509,7 +570,16 @@ class DataAdapter { : this._dataStructure; each(dataStructure, (_index: number, node: InternalNode) => { - if (node && this._isNodeVisible(node)) { + if (!this._isNodeVisible(node)) { + return; + } + + if (this.options.indirectSelectionMode === 'all') { + this._setFieldState(node, SELECTED, state); + return; + } + + if (!this._isNodeDisabled(node)) { this._setFieldState(node, SELECTED, state); } }); @@ -522,10 +592,25 @@ class DataAdapter { } isAllSelected(): boolean | undefined { - if (this.getSelectedNodesKeys().length) { - return this.getSelectedNodesKeys().length === this.getVisibleItemsCount() ? true : undefined; + const selectedNodesAmount = new Set(this.getSelectedNodesKeys()); + const disabledNodesAmount = new Set(this.getDisabledNodesKeys()); + + // @ts-expect-error ts-error + const selectedDisabledNodesAmount = selectedNodesAmount.intersection(disabledNodesAmount).size; + + const isSkipDisabled = this.options.indirectSelectionMode === 'skipDisabled'; + + const selectedNodesKeysAmount = this.getSelectedNodesKeys().length + - (isSkipDisabled ? selectedDisabledNodesAmount : 0); + + if (!selectedNodesKeysAmount) { + return false; } - return false; + + const subtractedNodesAmount = this.getVisibleItemsCount() + - (isSkipDisabled ? this._getDisabledItemsCount() : 0); + + return selectedNodesKeysAmount === subtractedNodesAmount ? true : undefined; } toggleExpansion(key: ItemKey, state: boolean): void { diff --git a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_converter.ts b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_converter.ts index 396ff865186f..4f0f1c367662 100644 --- a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_converter.ts +++ b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_converter.ts @@ -72,6 +72,8 @@ class DataConverter { private _visibleItemsCount = 0; + private _disabledItemsCount = 0; + _indexByKey: Record = {}; private _dataAccessors!: DataAccessors; @@ -128,6 +130,10 @@ class DataConverter { this._visibleItemsCount += 1; } + if (item.disabled === true) { + this._disabledItemsCount += 1; + } + const { items, ...itemWithoutItems } = item; const node = { @@ -265,6 +271,10 @@ class DataConverter { return this._itemsCount; } + getDisabledItemsCount(): number { + return this._disabledItemsCount; + } + getVisibleItemsCount(): number { return this._visibleItemsCount; } @@ -302,6 +312,7 @@ class DataConverter { ): (InternalNode | null)[] { this._itemsCount = 0; this._visibleItemsCount = 0; + this._disabledItemsCount = 0; this._rootValue = rootValue; this._dataType = dataType; this._indexByKey = {}; diff --git a/packages/devextreme/js/__internal/ui/tree_view/tree_view.base.ts b/packages/devextreme/js/__internal/ui/tree_view/tree_view.base.ts index fd4680d6cbfd..cb4246b6fdca 100644 --- a/packages/devextreme/js/__internal/ui/tree_view/tree_view.base.ts +++ b/packages/devextreme/js/__internal/ui/tree_view/tree_view.base.ts @@ -38,7 +38,7 @@ import type { SupportedKeys } from '@ts/core/widget/widget'; import CheckBox from '@ts/ui/check_box/index'; import type { ActionArgs, CollectionItemInfo, PostprocessRenderItemInfo } from '@ts/ui/collection/collection_widget.base'; import type { CollectionWidgetEditProperties } from '@ts/ui/collection/collection_widget.edit'; -import type { DataAdapterOptions } from '@ts/ui/hierarchical_collection/data_adapter'; +import type { DataAdapterOptions, IndirectSelectionMode } from '@ts/ui/hierarchical_collection/data_adapter'; import type { InternalNode, ItemData, ItemKey, PublicNode, } from '@ts/ui/hierarchical_collection/data_converter'; @@ -88,6 +88,7 @@ export const EXPANDER_ICON_STUB_CLASS = `${WIDGET_CLASS}-expander-icon-stub`; type TreeViewItem = Item & { url?: string; }; + type TreeViewNode = InternalNode & TreeViewItem; export interface TreeViewBaseProperties extends Properties, Omit< @@ -97,6 +98,8 @@ export interface TreeViewBaseProperties extends Properties, Omit< deferRendering?: boolean; _supportItemUrl?: boolean; + + indirectSelectionMode?: IndirectSelectionMode; } class TreeViewBase extends HierarchicalCollectionWidget { @@ -281,6 +284,7 @@ class TreeViewBase extends HierarchicalCollectionWidget { + [ + { indirectSelectionMode: 'all', ariaCheckedStates: ['mixed', 'mixed', 'true', 'false'] }, + { indirectSelectionMode: 'skipDisabled', ariaCheckedStates: ['false', 'false', 'true', 'false'] } + ].forEach((config) => { + QUnit.test(`initial selection state should be correct when indirectSelectionMode: ${config.indirectSelectionMode}`, function(assert) { + const treeView = initTree({ + items: this.items, + showCheckBoxesMode: 'normal', + indirectSelectionMode: config.indirectSelectionMode, + }).dxTreeView('instance'); + + const checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, config.ariaCheckedStates[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + + QUnit.module('selectAll', { + beforeEach: function(module) { + this.selectedItems = [{ + id: 1, + selected: true, + expanded: true, + items: [{ + id: 2, + selected: true, + disabled: true, + expanded: true, + items: [{ + id: 3, + selected: true, + }], + }], + }]; + + this.unselectedItems = [{ + id: 1, + expanded: true, + items: [{ + id: 2, + disabled: true, + expanded: true, + items: [{ + id: 3, + }], + }], + }]; + }, + }, () => { + [ + { indirectSelectionMode: 'all', ariaCheckedStates: ['true', 'true', 'true'] }, + { indirectSelectionMode: 'skipDisabled', ariaCheckedStates: ['true', 'false', 'true'] } + ].forEach((config) => { + QUnit.test(`selectAll should work correct when indirectSelectionMode: ${config.indirectSelectionMode}`, function(assert) { + const treeView = initTree({ + items: this.unselectedItems, + showCheckBoxesMode: 'normal', + indirectSelectionMode: config.indirectSelectionMode, + }).dxTreeView('instance'); + + treeView.selectAll(); + + const checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, config.ariaCheckedStates[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + + [ + { indirectSelectionMode: 'all', ariaCheckedStates: ['false', 'false', 'false'] }, + { indirectSelectionMode: 'skipDisabled', ariaCheckedStates: ['false', 'true', 'false'] } + ].forEach((config) => { + QUnit.test(`unselectAll should work correct when indirectSelectionMode: ${config.indirectSelectionMode}`, function(assert) { + const treeView = initTree({ + items: this.selectedItems, + showCheckBoxesMode: 'normal', + indirectSelectionMode: config.indirectSelectionMode, + }).dxTreeView('instance'); + + treeView.unselectAll(); + + const checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, config.ariaCheckedStates[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + + // TODO: think about test from false to true for indirectSelectionMode + QUnit.test('change option indirectSelectionMode in runtime should change modes correctly', function(assert) { + const selectedStatesBefore = ['true', 'true', 'true']; + const unselectedStatesAfter = ['false', 'true', 'false']; + + const treeView = initTree({ + items: this.unselectedItems, + showCheckBoxesMode: 'normal', + indirectSelectionMode: 'all', + }).dxTreeView('instance'); + + treeView.selectAll(); + + let checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, selectedStatesBefore[index], `checkbox ${index} has correct aria-checked state`); + }); + + treeView.option('indirectSelectionMode', 'skipDisabled'); + + treeView.unselectAll(); + + checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, unselectedStatesAfter[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + + QUnit.module('recursive selection', () => { + [ + { indirectSelectionMode: 'all', ariaCheckedStates: ['true', 'true', 'true', 'true'] }, + { indirectSelectionMode: 'skipDisabled', ariaCheckedStates: ['true', 'false', 'true', 'false'] } + ].forEach((config) => { + QUnit.test(`from parent to children when indirectSelectionMode = ${config.indirectSelectionMode}`, function(assert) { + const treeView = initTree({ + items: this.items, + showCheckBoxesMode: 'normal', + indirectSelectionMode: config.indirectSelectionMode, + }).dxTreeView('instance'); + + treeView.selectItem(1); + + const checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, config.ariaCheckedStates[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + + [ + { indirectSelectionMode: 'all', ariaCheckedStates: ['true', 'true', 'true', 'true'] }, + { indirectSelectionMode: 'skipDisabled', ariaCheckedStates: ['false', 'false', 'true', 'true'] } + ].forEach((config) => { + QUnit.test(`from children to parent when indirectSelectionMode = ${config.indirectSelectionMode}`, function(assert) { + const treeView = initTree({ + items: this.items, + showCheckBoxesMode: 'normal', + indirectSelectionMode: config.indirectSelectionMode, + }).dxTreeView('instance'); + + treeView.selectItem(4); + + const checkboxes = $(treeView.$element()).find(`.${CHECKBOX_CLASS}`); + + checkboxes.each((index, checkbox) => { + const ariaChecked = $(checkbox).attr('aria-checked'); + assert.strictEqual(ariaChecked, config.ariaCheckedStates[index], `checkbox ${index} has correct aria-checked state`); + }); + }); + }); + }); +}); + QUnit.module('T988756', () => { QUnit.test('showCheckBoxesMode=none -> showCheckBoxesMode=selectAll and click -> showCheckBoxesMode=none and click -> showCheckBoxesMode=selectAll and click (T988756)', function(assert) { const selectAllStub = sinon.stub();