From 8244c97b346ae27ee0c930bb1473707d170a4bd0 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Fri, 4 Sep 2026 19:35:21 +0800 Subject: [PATCH] implement --- .../m_data_source_adapter.ts | 2 +- .../m_data_source_adapter.ts | 185 ++--- .../tree_list/data_source_adapter/types.ts | 22 + .../utils/__tests__/nodes.test.ts | 637 ++++++++++++++++++ .../data_source_adapter/utils/nodes.ts | 194 ++++++ 5 files changed, 902 insertions(+), 138 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/nodes.test.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts index 4b135fbd8680..2a4db0edfc7c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts @@ -673,7 +673,7 @@ export default class DataSourceAdapter extends modules.Controller { /** * @extended: TreeLists's data_source_adapter */ - protected customizeLoadResultHandlerCore(options) { + protected customizeLoadResultHandlerCore(options: LoadOperation): void { if (options.remoteOperations && !options.remoteOperations.paging && Array.isArray(options.data)) { if (options.skip !== undefined) { options.data = options.data.slice(options.skip); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts index b76ec5607f45..9939db80c2fd 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts @@ -15,9 +15,15 @@ import type { BeforePushEvent } from '@ts/data/types'; import type { CustomLoadResult } from '@ts/grids/grid_core/data_source_adapter/custom_loader'; import DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import { createDataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/provider'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import treeListCore from '../m_core'; +import type { LoadOperation, NodeByKey, TreeNode } from './types'; +import type { NodesContext } from './utils/nodes'; +import { + convertItemToNode, createNodesByItems, fillNodes, getVisibleNodes, +} from './utils/nodes'; const { queryByOptions } = storeHelper; @@ -67,7 +73,7 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { private _isChildrenLoaded: any; - private _nodeByKey: any; + private _nodeByKey!: NodeByKey; private _isReload: any; @@ -139,85 +145,15 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { }); } - private _calculateHasItems(node, options) { - const that = this; - const { parentIds } = options.storeLoadOptions; - let hasItems; - const isFullBranch = isFullBranchFilterMode(that); - - if (that._hasItemsGetter && (parentIds || !options.storeLoadOptions.filter || isFullBranch)) { - hasItems = that._hasItemsGetter(node.data); - } - - if (hasItems === undefined) { - if (!that._isChildrenLoaded[node.key] && options.remoteOperations.filtering && (parentIds || isFullBranch)) { - hasItems = true; - } else if (options.loadOptions.filter && !options.remoteOperations.filtering && isFullBranch) { - hasItems = node.children.length; - } else { - hasItems = node.hasChildren; - } - } - return !!hasItems; - } - - private _fillVisibleItemsByNodes(nodes, options, result) { - for (let i = 0; i < nodes.length; i++) { - if (nodes[i].visible) { - result.push(nodes[i]); - } - - if ((this.isRowExpanded(nodes[i].key, options) || !nodes[i].visible) && nodes[i].hasChildren && nodes[i].children.length) { - this._fillVisibleItemsByNodes(nodes[i].children, options, result); - } - } - } - - private _convertItemToNode(item, rootValue, nodeByKey) { - const key = this._keyGetter(item); - let parentId = this._parentIdGetter(item); - - parentId = isDefined(parentId) ? parentId : rootValue; - const parentNode = nodeByKey[parentId] = nodeByKey[parentId] || { key: parentId, children: [] }; - - const node = nodeByKey[key] = nodeByKey[key] || { key, children: [] }; - node.data = item; - node.parent = parentNode; - - return node; - } - - private _createNodesByItems(items, visibleItems) { - const that = this; - const rootValue: any = that.option('rootValue'); - const visibleByKey = {}; - const nodeByKey = that._nodeByKey = {}; - let i; - - if (visibleItems) { - for (i = 0; i < visibleItems.length; i++) { - visibleByKey[this._keyGetter(visibleItems[i])] = true; - } - } - - for (i = 0; i < items.length; i++) { - const node = that._convertItemToNode(items[i], rootValue, nodeByKey); - - if (node.key === undefined) { - return; - } - - node.visible = !visibleItems || !!visibleByKey[node.key]; - if (node.parent) { - node.parent.children.push(node); - } - } - - const rootNode = nodeByKey[rootValue] || { key: rootValue, children: [] }; - - rootNode.level = -1; - - return rootNode; + private _getNodesContext(): NodesContext { + return { + rootValue: this.option('rootValue'), + isFullBranchFilterMode: isFullBranchFilterMode(this), + keyGetter: this._keyGetter, + parentIdGetter: this._parentIdGetter, + hasItemsGetter: this._hasItemsGetter, + isChildrenLoaded: this._isChildrenLoaded, + }; } private _convertDataToPlainStructure(data, parentId?, result?) { @@ -438,11 +374,13 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { return d.resolve(data); } - let cachedNodes = keys.map((id) => this.getNodeByKey(id)).filter((node) => node && node.data); + let cachedNodes = keys + .map((id) => this.getNodeByKey(id)) + .filter((node) => node && node.data) as TreeNode[]; if (cachedNodes.length === keys.length) { if (needChildren) { - cachedNodes = cachedNodes.reduce((result, node) => result.concat(node.children), []); + cachedNodes = cachedNodes.reduce((result: TreeNode[], node) => result.concat(node.children), []); } if (cachedNodes.length) { @@ -571,20 +509,20 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { const parentNode = that.getNodeByKey(parentId); if (parentNode) { - const rootValue = that.option('rootValue'); - const node = that._convertItemToNode(change.data, rootValue, that._nodeByKey); + const node = convertItemToNode(change.data, that._nodeByKey, that._getNodesContext()); node.hasChildren = false; - node.level = parentNode.level + 1; + node.level = parentNode.level! + 1; node.visible = true; parentNode.children.push(node); - that._isChildrenLoaded[node.key] = true; + that._isChildrenLoaded[node.key as string] = true; that._setHasItems(parentNode, true); if ((!parentNode.parent || that.isRowExpanded(parentNode.key)) && change.index !== undefined) { + // @ts-expect-error Badly typed items() let index = that.items().indexOf(parentNode) + 1; index += change.index >= 0 ? Math.min(change.index, parentNode.children.length) : parentNode.children.length; @@ -640,61 +578,29 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { } } - private _fillNodes(nodes, options, expandedRowKeys, level?) { - const isFullBranch = isFullBranchFilterMode(this); - - level = level || 0; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - let needToExpand = false; - - // node.hasChildren = false; - this._fillNodes(nodes[i].children, options, expandedRowKeys, level + 1); - - node.level = level; - node.hasChildren = this._calculateHasItems(node, options); - - if (node.visible && node.hasChildren) { - if (isFullBranch) { - if (node.children.filter((node) => node.visible).length) { - needToExpand = true; - } else if (node.children.length) { - treeListCore.foreachNodes(node.children, (node) => { - node.visible = true; - }); - } - } else { - needToExpand = true; - } - if (options.expandVisibleNodes && needToExpand) { - expandedRowKeys.push(node.key); - } - } - - if (node.visible || node.hasChildren) { - node.parent.hasChildren = true; - } - } - } - - private _processTreeStructure(options, visibleItems?) { - let { data } = options; + private _processTreeStructure(options: LoadOperation, visibleItems?: RawItemData[]): void { + let data = options.data as RawItemData[]; const { parentIds } = options.storeLoadOptions; - const expandedRowKeys = []; if (parentIds && parentIds.length || this._isReload) { if (options.fullData) { data = options.fullData; - visibleItems = visibleItems || options.data; + visibleItems ??= options.data as RawItemData[]; } - this._rootNode = this._createNodesByItems(data, visibleItems); + const nodesContext = this._getNodesContext(); + const { rootNode, nodeByKey } = createNodesByItems(data, visibleItems, nodesContext); + + this._nodeByKey = nodeByKey; + this._rootNode = rootNode; + if (!this._rootNode) { - // @ts-expect-error - options.data = new Deferred().reject(errors.Error('E1046', this.getKeyExpr())); + // @ts-expect-error badly typed Deferred + options.data = Deferred().reject(errors.Error('E1046', this.getKeyExpr())); return; } - this._fillNodes(this._rootNode.children, options, expandedRowKeys); + + const expandedRowKeys = fillNodes(this._rootNode.children, options, nodesContext); this._isNodesInitializing = true; if (options.collapseVisibleNodes || expandedRowKeys.length) { @@ -705,18 +611,20 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { this._isNodesInitializing = false; } - const resultData = []; - - this._fillVisibleItemsByNodes(this._rootNode.children, options, resultData); + const resultData = getVisibleNodes( + this._rootNode.children, + (key) => this.isRowExpanded(key, options), + ); + // @ts-expect-error From here on the rows are nodes, not the loaded items the base type describes. options.data = resultData; this._totalItemsCount = resultData.length; } - protected customizeLoadResultHandlerCore(options) { + protected customizeLoadResultHandlerCore(options: LoadOperation): void { const that = this; const { data } = options; - const filter = options.storeLoadOptions.filter || options.loadOptions.filter; + const filter = options.storeLoadOptions.filter || options.loadOptions?.filter; const filterMode = that.option('filterMode'); let visibleItems; const { parentIds } = options.storeLoadOptions; @@ -730,6 +638,7 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { if (filterMode === 'matchOnly') { visibleItems = data; } + return that._loadParents(data, options).done((data) => { that._loadChildrenIfNeed(data, options).done((data) => { options.data = data; @@ -847,10 +756,12 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { return this._isNodesInitializing ? new Deferred().resolve() : this.load(); } - public getNodeByKey(key) { + public getNodeByKey(key): TreeNode | undefined { if (this._nodeByKey) { return this._nodeByKey[key]; } + + return undefined; } private getNodeLeafKeys() { diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts new file mode 100644 index 000000000000..443ab021fa11 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts @@ -0,0 +1,22 @@ +import type { LoadOperation as BaseLoadOperation, RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; + +export interface LoadOperation extends BaseLoadOperation { + storeLoadOptions: BaseLoadOperation['storeLoadOptions'] & { + parentIds?: unknown[]; + }; + fullData?: RawItemData[]; + collapseVisibleNodes?: boolean; + expandVisibleNodes?: boolean; +} + +export interface TreeNode { + key: unknown; + children: TreeNode[]; + data?: unknown; + parent?: TreeNode; + level?: number; + visible?: boolean; + hasChildren?: boolean; +} + +export type NodeByKey = Record; diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/nodes.test.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/nodes.test.ts new file mode 100644 index 000000000000..ea1141373c60 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/nodes.test.ts @@ -0,0 +1,637 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { LoadOperation, NodeByKey, TreeNode } from '../../types'; +import type { NodesContext } from '../nodes'; +import { + convertItemToNode, createNodesByItems, fillNodes, getVisibleNodes, +} from '../nodes'; + +interface Item { + [field: string]: unknown; + id?: unknown; + parentId?: unknown; + hasItems?: unknown; +} + +const ROOT = 0; + +function createContext(overrides?: Partial): NodesContext { + return { + rootValue: ROOT, + isFullBranchFilterMode: false, + keyGetter: (data): unknown => (data as Item).id, + parentIdGetter: (data): unknown => (data as Item).parentId, + hasItemsGetter: undefined, + isChildrenLoaded: {}, + ...overrides, + }; +} + +function createLoadOptions(options: { + parentIds?: unknown[]; + storeFilter?: unknown; + loadFilter?: unknown; + remoteFiltering?: boolean; + expandVisibleNodes?: boolean; +} = {}): LoadOperation { + return { + storeLoadOptions: { + parentIds: options.parentIds, + filter: options.storeFilter, + }, + loadOptions: { + filter: options.loadFilter, + }, + remoteOperations: { + filtering: options.remoteFiltering, + }, + expandVisibleNodes: options.expandVisibleNodes, + }; +} + +function buildTree(items: Item[], visibleKeys?: unknown[]): { root: TreeNode; nodes: NodeByKey } { + const visibleItems = visibleKeys && items.filter((item) => visibleKeys.includes(item.id)); + const { rootNode, nodeByKey } = createNodesByItems(items, visibleItems, createContext()); + + return { root: rootNode as TreeNode, nodes: nodeByKey }; +} + +describe('convertItemToNode', () => { + it('wires the node to a placeholder parent and registers both by key', () => { + const nodeByKey = {}; + const item = { id: 2, parentId: 1 }; + + const node = convertItemToNode(item, nodeByKey, createContext()); + + expect(node).toMatchObject({ key: 2, data: item, children: [] }); + expect(node.parent).toEqual({ key: 1, children: [] }); + expect(nodeByKey).toEqual({ 1: node.parent, 2: node }); + }); + + const rootFallbackCases: [string, unknown][] = [ + ['undefined', undefined], + ['null', null], + ]; + + it.each(rootFallbackCases)('attaches the item to rootValue when its parent id is %s', (_, parentId) => { + const node = convertItemToNode({ id: 1, parentId }, {}, createContext()); + + expect(node.parent?.key).toBe(ROOT); + }); + + it('reuses an already registered node, keeping the children it accumulated', () => { + const nodeByKey = {}; + const context = createContext(); + + // The child is converted first, so node `1` only exists as a placeholder parent. + const child = convertItemToNode({ id: 2, parentId: 1 }, nodeByKey, context); + child.parent?.children.push(child); + + const parentItem = { id: 1, parentId: ROOT }; + const parent = convertItemToNode(parentItem, nodeByKey, context); + + expect(parent).toBe(child.parent); + expect(parent.data).toBe(parentItem); + expect(parent.children).toEqual([child]); + }); + + it('reuses an already registered parent', () => { + const nodeByKey = {}; + const context = createContext(); + + const first = convertItemToNode({ id: 2, parentId: 1 }, nodeByKey, context); + const second = convertItemToNode({ id: 3, parentId: 1 }, nodeByKey, context); + + expect(second.parent).toBe(first.parent); + }); +}); + +describe('createNodesByItems', () => { + it('builds a tree and indexes every node by key', () => { + const items = [ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 1 }, + ]; + + const { rootNode, nodeByKey } = createNodesByItems(items, undefined, createContext()); + + expect(rootNode?.key).toBe(ROOT); + expect(rootNode?.level).toBe(-1); + expect(rootNode?.children.map((node) => node.key)).toEqual([1]); + expect(rootNode?.children[0].children.map((node) => node.key)).toEqual([2, 3]); + expect(Object.keys(nodeByKey)).toEqual(['0', '1', '2', '3']); + }); + + it('does not depend on item order — a child may precede its parent', () => { + const items = [ + { id: 2, parentId: 1 }, + { id: 1, parentId: ROOT }, + ]; + + const { rootNode } = createNodesByItems(items, undefined, createContext()); + + expect(rootNode?.children.map((node) => node.key)).toEqual([1]); + expect(rootNode?.children[0].children.map((node) => node.key)).toEqual([2]); + expect(rootNode?.children[0].data).toBe(items[1]); + }); + + it('leaves an orphaned branch out of the root, but keeps it indexed', () => { + const items = [{ id: 2, parentId: 'missing' }]; + + const { rootNode, nodeByKey } = createNodesByItems(items, undefined, createContext()); + + expect(rootNode?.children).toEqual([]); + expect(nodeByKey.missing.children.map((node) => node.key)).toEqual([2]); + }); + + const rootValueCases: [string, unknown][] = [ + ['null', null], + ['a string', 'root'], + ]; + + it.each(rootValueCases)('supports %s as rootValue', (_, rootValue) => { + const items = [{ id: 1, parentId: rootValue }]; + + const { rootNode } = createNodesByItems(items, undefined, createContext({ rootValue })); + + expect(rootNode?.key).toBe(rootValue); + expect(rootNode?.children.map((node) => node.key)).toEqual([1]); + }); + + it('returns a synthetic root when there are no items', () => { + const { rootNode, nodeByKey } = createNodesByItems([], undefined, createContext()); + + expect(rootNode).toEqual({ key: ROOT, children: [], level: -1 }); + expect(nodeByKey).toEqual({}); + }); + + it('marks every node visible when visibleItems is not passed', () => { + const items = [{ id: 1, parentId: ROOT }, { id: 2, parentId: 1 }]; + + const { nodeByKey } = createNodesByItems(items, undefined, createContext()); + + expect(nodeByKey[1].visible).toBe(true); + expect(nodeByKey[2].visible).toBe(true); + }); + + it('marks only the nodes listed in visibleItems visible', () => { + const items = [{ id: 1, parentId: ROOT }, { id: 2, parentId: 1 }]; + + const { nodeByKey } = createNodesByItems(items, [items[1]], createContext()); + + expect(nodeByKey[1].visible).toBe(false); + expect(nodeByKey[2].visible).toBe(true); + }); + + it('hides every node when visibleItems is an empty array', () => { + const items = [{ id: 1, parentId: ROOT }]; + + const { nodeByKey } = createNodesByItems(items, [], createContext()); + + expect(nodeByKey[1].visible).toBe(false); + }); + + it('bails out without a root node when an item has no key (E1046)', () => { + const items = [ + { id: 1, parentId: ROOT }, + { id: undefined, parentId: 1 }, + { id: 3, parentId: 1 }, + ]; + + const { rootNode, nodeByKey } = createNodesByItems(items, undefined, createContext()); + + expect(rootNode).toBeUndefined(); + // The adapter keeps this partially filled map, so the nodes seen so far stay indexed. + expect(nodeByKey[1]).toMatchObject({ key: 1 }); + expect(nodeByKey[3]).toBeUndefined(); + }); +}); + +describe('fillNodes', () => { + it('assigns the level of every node depth first', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + { id: 4, parentId: ROOT }, + ]); + + fillNodes(root.children, createLoadOptions(), createContext()); + + expect([1, 2, 3, 4].map((key) => nodes[key].level)).toEqual([0, 1, 2, 0]); + }); + + it('returns no keys for an empty node list', () => { + expect(fillNodes([], createLoadOptions(), createContext())).toEqual([]); + }); + + describe('hasChildren propagation', () => { + it('propagates up from the visible leaves', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ], [3]); + + fillNodes(root.children, createLoadOptions(), createContext()); + + expect([1, 2, 3].map((key) => nodes[key].hasChildren)).toEqual([true, true, false]); + expect(root.hasChildren).toBe(true); + }); + + it('stays false on a branch of hidden leaves', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + ], []); + + fillNodes(root.children, createLoadOptions(), createContext()); + + expect(nodes[1].hasChildren).toBe(false); + expect(root.hasChildren).toBeUndefined(); + }); + + it('is overridden by hasItemsExpr on the node itself', () => { + const context = createContext({ + hasItemsGetter: (data): unknown => (data as Item).hasItems, + }); + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT, hasItems: false }, + { id: 2, parentId: 1 }, + ], [2]); + + fillNodes(root.children, createLoadOptions(), context); + + expect(nodes[1].hasChildren).toBe(false); + }); + }); + + describe('hasChildren of one node', () => { + interface Fixture { + items: Item[]; + visibleKeys: unknown[]; + } + + const leaf: Fixture = { + items: [{ id: 1, parentId: ROOT }], + visibleKeys: [], + }; + + const withVisibleChild: Fixture = { + items: [{ id: 1, parentId: ROOT }, { id: 2, parentId: 1 }], + visibleKeys: [2], + }; + + const withHiddenChild: Fixture = { + items: [{ id: 1, parentId: ROOT }, { id: 2, parentId: 1 }], + visibleKeys: [], + }; + + function calculateHasChildren( + fixture: Fixture, + options: LoadOperation, + context: NodesContext, + ): boolean | undefined { + const { root, nodes } = buildTree(fixture.items, fixture.visibleKeys); + + fillNodes(root.children, options, context); + + return nodes[1].hasChildren; + } + + describe('hasItemsExpr', () => { + const getterCases: [string, unknown, boolean][] = [ + ['true', true, true], + ['false', false, false], + ]; + + it.each(getterCases)('follows the getter when it returns %s', (_, hasItems, expected) => { + const context = createContext({ hasItemsGetter: () => hasItems }); + + // Shaped so the fallback would answer the opposite: the getter has to win. + const fixture = expected ? leaf : withVisibleChild; + + expect(calculateHasChildren(fixture, createLoadOptions(), context)).toBe(expected); + }); + + it('falls back to the children when the getter returns undefined', () => { + const context = createContext({ hasItemsGetter: () => undefined }); + + expect(calculateHasChildren(withVisibleChild, createLoadOptions(), context)).toBe(true); + }); + + it('is ignored while a store filter is applied', () => { + const context = createContext({ hasItemsGetter: () => true }); + const options = createLoadOptions({ storeFilter: ['id', '=', 1] }); + + expect(calculateHasChildren(leaf, options, context)).toBe(false); + }); + + it('is used under a store filter when parentIds are requested', () => { + const context = createContext({ hasItemsGetter: () => true }); + const options = createLoadOptions({ storeFilter: ['id', '=', 1], parentIds: [ROOT] }); + + expect(calculateHasChildren(leaf, options, context)).toBe(true); + }); + + // Pins current behaviour: an empty parentIds array is truthy here, while + // _processTreeStructure treats it as "no parent ids requested". + it('is used under a store filter when parentIds is an empty array', () => { + const context = createContext({ hasItemsGetter: () => true }); + const options = createLoadOptions({ storeFilter: ['id', '=', 1], parentIds: [] }); + + expect(calculateHasChildren(leaf, options, context)).toBe(true); + }); + + it('is used under a store filter in fullBranch mode', () => { + const context = createContext({ + hasItemsGetter: () => true, + isFullBranchFilterMode: true, + }); + const options = createLoadOptions({ storeFilter: ['id', '=', 1] }); + + expect(calculateHasChildren(leaf, options, context)).toBe(true); + }); + }); + + describe('children not loaded yet', () => { + it('assumes items exist under remote filtering when parentIds are requested', () => { + const options = createLoadOptions({ remoteFiltering: true, parentIds: [ROOT] }); + + expect(calculateHasChildren(leaf, options, createContext())).toBe(true); + }); + + it('assumes items exist under remote filtering in fullBranch mode', () => { + const context = createContext({ isFullBranchFilterMode: true }); + const options = createLoadOptions({ remoteFiltering: true }); + + expect(calculateHasChildren(leaf, options, context)).toBe(true); + }); + + it('assumes nothing once the children are loaded', () => { + const context = createContext({ isChildrenLoaded: { 1: true } }); + const options = createLoadOptions({ remoteFiltering: true, parentIds: [ROOT] }); + + expect(calculateHasChildren(leaf, options, context)).toBe(false); + }); + + it('assumes nothing without remote filtering', () => { + const options = createLoadOptions({ parentIds: [ROOT] }); + + expect(calculateHasChildren(leaf, options, createContext())).toBe(false); + }); + }); + + describe('local filtering in fullBranch mode', () => { + const options = createLoadOptions({ loadFilter: ['id', '=', 1] }); + const context = createContext({ isFullBranchFilterMode: true }); + + it('reports items for any child that survived the filter, visible or not', () => { + expect(calculateHasChildren(withHiddenChild, options, context)).toBe(true); + }); + + it('reports no items when every child was filtered out', () => { + expect(calculateHasChildren(leaf, options, context)).toBe(false); + }); + }); + + describe('fallback', () => { + it('reports items once a descendant propagated them', () => { + expect(calculateHasChildren(withVisibleChild, createLoadOptions(), createContext())) + .toBe(true); + }); + + it('reports none for a node nothing propagated into', () => { + expect(calculateHasChildren(leaf, createLoadOptions(), createContext())).toBe(false); + }); + }); + }); + + describe('expandedRowKeys', () => { + it('collects the keys to expand deepest first', () => { + const { root } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ]); + const expandedRowKeys = fillNodes( + root.children, + createLoadOptions({ expandVisibleNodes: true }), + createContext(), + ); + + expect(expandedRowKeys).toEqual([2, 1]); + }); + + it('collects nothing when expandVisibleNodes is not set', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + ]); + const expandedRowKeys = fillNodes(root.children, createLoadOptions(), createContext()); + + expect(expandedRowKeys).toEqual([]); + expect(nodes[1].hasChildren).toBe(true); + }); + + it('skips a node that is hidden itself', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + ], [2]); + const expandedRowKeys = fillNodes( + root.children, + createLoadOptions({ expandVisibleNodes: true }), + createContext(), + ); + + expect(nodes[1].hasChildren).toBe(true); + expect(expandedRowKeys).toEqual([]); + }); + + it('skips a node without children', () => { + const { root } = buildTree([{ id: 1, parentId: ROOT }]); + const expandedRowKeys = fillNodes( + root.children, + createLoadOptions({ expandVisibleNodes: true }), + createContext(), + ); + + expect(expandedRowKeys).toEqual([]); + }); + }); + + describe('fullBranch mode', () => { + const context = createContext({ + isFullBranchFilterMode: true, + hasItemsGetter: (data): unknown => (data as Item).hasItems, + }); + const options = createLoadOptions({ expandVisibleNodes: true }); + + it('expands a node that kept at least one visible child', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 1 }, + ], [1, 2]); + const expandedRowKeys = fillNodes(root.children, options, context); + + expect(expandedRowKeys).toEqual([1]); + expect(nodes[3].visible).toBe(false); + }); + + it('reveals the branch instead of expanding it when every child was filtered out', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT, hasItems: true }, + { id: 2, parentId: 1 }, + ], [1]); + const expandedRowKeys = fillNodes(root.children, options, context); + + expect(expandedRowKeys).toEqual([]); + expect(nodes[2].visible).toBe(true); + }); + + it('stops revealing a branch at a node that reports no items of its own', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT, hasItems: true }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ], [1]); + + fillNodes(root.children, options, context); + + // foreachNodes only descends into nodes whose hasChildren is already truthy, + // and node 2 reports none once its own children were filtered out. + expect(nodes[2].visible).toBe(true); + expect(nodes[3].visible).toBe(false); + }); + + it('keeps revealing a branch through a node that reports items', () => { + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT, hasItems: true }, + { id: 2, parentId: 1, hasItems: true }, + { id: 3, parentId: 2 }, + ], [1]); + + fillNodes(root.children, options, context); + + expect(nodes[3].visible).toBe(true); + }); + + it('handles a node that reports items but has no loaded children', () => { + const { root, nodes } = buildTree([{ id: 1, parentId: ROOT, hasItems: true }], [1]); + const expandedRowKeys = fillNodes(root.children, options, context); + + expect(expandedRowKeys).toEqual([]); + expect(nodes[1].hasChildren).toBe(true); + }); + + it('expands a visible node with children when the mode is off', () => { + const plainContext = createContext({ + hasItemsGetter: (data): unknown => (data as Item).hasItems, + }); + const { root, nodes } = buildTree([ + { id: 1, parentId: ROOT, hasItems: true }, + { id: 2, parentId: 1 }, + ], [1]); + const expandedRowKeys = fillNodes(root.children, options, plainContext); + + expect(expandedRowKeys).toEqual([1]); + expect(nodes[2].visible).toBe(false); + }); + }); +}); + +describe('getVisibleNodes', () => { + const noneExpanded = (): boolean => false; + const allExpanded = (): boolean => true; + + // Mirrors the adapter: the tree is filled first, then flattened into rows. + function getVisibleKeys( + items: Item[], + visibleKeys: unknown[] | undefined, + isRowExpanded: (key: unknown) => boolean, + context = createContext(), + ): unknown[] { + const { root } = buildTree(items, visibleKeys); + + fillNodes(root.children, createLoadOptions(), context); + + return getVisibleNodes(root.children, isRowExpanded).map((node) => node.key); + } + + const parentAndChild: Item[] = [ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + ]; + + it('returns the node objects themselves', () => { + const { root, nodes } = buildTree([{ id: 1, parentId: ROOT }]); + + fillNodes(root.children, createLoadOptions(), createContext()); + + expect(getVisibleNodes(root.children, noneExpanded)[0]).toBe(nodes[1]); + }); + + it('returns nothing for an empty node list', () => { + expect(getVisibleNodes([], noneExpanded)).toEqual([]); + }); + + it('stops at a collapsed node', () => { + expect(getVisibleKeys(parentAndChild, undefined, noneExpanded)).toEqual([1]); + }); + + it('includes the children of an expanded node', () => { + expect(getVisibleKeys(parentAndChild, undefined, allExpanded)).toEqual([1, 2]); + }); + + it('returns the nodes in tree order', () => { + const items = [ + { id: 1, parentId: ROOT }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + { id: 4, parentId: ROOT }, + ]; + + expect(getVisibleKeys(items, undefined, allExpanded)).toEqual([1, 2, 3, 4]); + }); + + // How matchOnly/fullBranch surface a match whose ancestors were filtered out. + it('descends through a hidden node without listing it', () => { + expect(getVisibleKeys(parentAndChild, [2], noneExpanded)).toEqual([2]); + }); + + it('does not descend into a node that reports no children', () => { + const context = createContext({ + hasItemsGetter: (data): unknown => (data as Item).hasItems, + }); + const items = [ + { id: 1, parentId: ROOT, hasItems: false }, + { id: 2, parentId: 1 }, + ]; + + expect(getVisibleKeys(items, undefined, allExpanded, context)).toEqual([1]); + }); + + it('handles a node that reports children it has not loaded', () => { + const context = createContext({ + hasItemsGetter: (data): unknown => (data as Item).hasItems, + }); + const items = [{ id: 1, parentId: ROOT, hasItems: true }]; + + expect(getVisibleKeys(items, undefined, allExpanded, context)).toEqual([1]); + }); + + it('asks about expansion using each node key', () => { + const asked: unknown[] = []; + + getVisibleKeys(parentAndChild, undefined, (key) => { + asked.push(key); + + return true; + }); + + expect(asked).toEqual([1, 2]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts new file mode 100644 index 000000000000..ffbb7bd84f30 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts @@ -0,0 +1,194 @@ +import { isDefined } from '@js/core/utils/type'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; + +import treeListCore from '../../m_core'; +import type { LoadOperation, NodeByKey, TreeNode } from '../types'; + +export interface NodesContext { + rootValue: unknown; + isFullBranchFilterMode: boolean; + keyGetter: (data: unknown) => unknown; + parentIdGetter: (data: unknown) => unknown; + hasItemsGetter?: (data: unknown) => unknown; + isChildrenLoaded: Record; +} + +export type ConvertContext = Pick; + +export type FillNodesContext = Pick; + +export function convertItemToNode( + item: unknown, + nodeByKey: NodeByKey, + context: ConvertContext, +): TreeNode { + const key = context.keyGetter(item) as string; + const itemParentId = context.parentIdGetter(item); + const parentId = (isDefined(itemParentId) ? itemParentId : context.rootValue) as string; + + const parentNode = nodeByKey[parentId] ?? { key: parentId, children: [] }; + nodeByKey[parentId] = parentNode; + + const node = nodeByKey[key] ?? { key, children: [] }; + nodeByKey[key] = node; + + node.data = item; + node.parent = parentNode; + + return node; +} + +export function createNodesByItems( + items: RawItemData[], + visibleItems: RawItemData[] | undefined, + context: ConvertContext, +): { rootNode?: TreeNode; nodeByKey: NodeByKey } { + const nodeByKey: NodeByKey = {}; + const visibleByKey: Record = {}; + + visibleItems?.forEach((item) => { + visibleByKey[context.keyGetter(item) as string] = true; + }); + + for (const item of items) { + const node = convertItemToNode(item, nodeByKey, context); + + if (node.key === undefined) { + return { nodeByKey }; + } + + node.visible = !visibleItems || !!visibleByKey[node.key as string]; + if (node.parent) { + node.parent.children.push(node); + } + } + + const rootNode = nodeByKey[context.rootValue as string] ?? { + key: context.rootValue, + children: [], + }; + + rootNode.level = -1; + + return { rootNode, nodeByKey }; +} + +function calculateHasChildren( + node: TreeNode, + options: LoadOperation, + context: FillNodesContext, +): boolean { + const { parentIds } = options.storeLoadOptions; + const isFullBranch = context.isFullBranchFilterMode; + const canUseHasItemsGetter = !!parentIds || !options.storeLoadOptions.filter || isFullBranch; + + const hasItemsFromData = context.hasItemsGetter && canUseHasItemsGetter + ? context.hasItemsGetter(node.data) + : undefined; + + if (hasItemsFromData !== undefined) { + return !!hasItemsFromData; + } + + const isChildrenLoaded = context.isChildrenLoaded[node.key as string]; + + if (!isChildrenLoaded && options.remoteOperations?.filtering && (parentIds || isFullBranch)) { + return true; + } + + if (options.loadOptions?.filter && !options.remoteOperations?.filtering && isFullBranch) { + return !!node.children.length; + } + + return !!node.hasChildren; +} + +// In `fullBranch` mode a node whose children are all filtered out stays collapsed +// and its whole branch becomes visible instead. +function resolveNeedToExpand(node: TreeNode, isFullBranch: boolean): boolean { + if (!isFullBranch) { + return true; + } + + if (node.children.some((child) => child.visible)) { + return true; + } + + if (node.children.length) { + treeListCore.foreachNodes(node.children, (child: TreeNode) => { + child.visible = true; + }); + } + + return false; +} + +function fillNodesCore( + nodes: TreeNode[], + options: LoadOperation, + context: FillNodesContext, + expandedRowKeys: unknown[], + level: number, +): void { + nodes.forEach((node) => { + fillNodesCore(node.children, options, context, expandedRowKeys, level + 1); + + node.level = level; + node.hasChildren = calculateHasChildren(node, options, context); + + if (node.visible && node.hasChildren) { + const needToExpand = resolveNeedToExpand(node, context.isFullBranchFilterMode); + + if (options.expandVisibleNodes && needToExpand) { + expandedRowKeys.push(node.key); + } + } + + if (node.parent && (node.visible || node.hasChildren)) { + node.parent.hasChildren = true; + } + }); +} + +export function fillNodes( + nodes: TreeNode[], + options: LoadOperation, + context: FillNodesContext, +): unknown[] { + const expandedRowKeys: unknown[] = []; + + fillNodesCore(nodes, options, context, expandedRowKeys, 0); + + return expandedRowKeys; +} + +function collectVisibleNodes( + nodes: TreeNode[], + isRowExpanded: (key: unknown) => boolean, + result: TreeNode[], +): void { + nodes.forEach((node) => { + if (node.visible) { + result.push(node); + } + + const shouldDescend = (isRowExpanded(node.key) || !node.visible) + && node.hasChildren + && node.children.length > 0; + + if (shouldDescend) { + collectVisibleNodes(node.children, isRowExpanded, result); + } + }); +} + +export function getVisibleNodes( + nodes: TreeNode[], + isRowExpanded: (key: unknown) => boolean, +): TreeNode[] { + const result: TreeNode[] = []; + + collectVisibleNodes(nodes, isRowExpanded, result); + + return result; +}