From 7e9635856ee1776529ae8a9b27d5570ae288a239 Mon Sep 17 00:00:00 2001 From: Sergei Burkatskii <5622446+sjbur@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:51:24 +0200 Subject: [PATCH 1/3] feat: prepare group utils for multilevel grouping --- .../resource_manager/group_utils.test.ts | 191 ++++++++++++++++-- .../utils/resource_manager/group_utils.ts | 108 ++++++++-- 2 files changed, 266 insertions(+), 33 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts index ed85fdb1379f..659fcdc9667e 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts @@ -1,30 +1,99 @@ import { - describe, expect, it, + beforeAll, describe, expect, it, } from '@jest/globals'; import { getResourceManagerMock } from '@ts/scheduler/__mock__/resource_manager.mock'; +import { ResourceLoader } from '../loader/resource_loader'; +import type { RawResourceData } from '../loader/types'; import { getAllGroupValues, getGroupTexts, getLeafGroupValues, getResourcesByGroupIndex, groupResources, } from './group_utils'; +import type { GroupLeaf } from './types'; -const groupsLeafs: any = [ - { groupIndex: 0, grouped: { assigneeId: 1, roomId: 3 } }, - { groupIndex: 1, grouped: { assigneeId: 3, roomId: 4 } }, - { groupIndex: 2, grouped: { roomId: 0 } }, - { groupIndex: 3, grouped: { assigneeId: 0, roomId: 0 } }, +const assigneeData: RawResourceData[] = [ + { id: 0, text: 'Samantha Bright', color: '#727bd2' }, + { id: 1, text: 'John Heart', color: '#32c9ed' }, ]; -const resourceById: any = { - assigneeId: { - resourceIndex: 'assigneeId', - items: [{ id: 0, text: 'Samantha Bright' }, { id: 1, text: 'John Heart' }], + +const roomData: RawResourceData[] = [ + { id: 0, text: 'Room 1', color: '#aaa' }, + { id: 1, text: 'Room 2', color: '#ccc' }, +]; + +const roomHierarchyData: RawResourceData[] = [ + { + id: 'board', text: 'Board rooms', color: '#111', parentId: null, + }, + { + id: 'open', text: 'Open spaces', color: '#222', parentId: null, + }, + { + id: 11, text: 'Room 11', color: '#333', parentId: 'board', }, - roomId: { - resourceIndex: 'roomId', - items: [{ id: 0, text: 'Room 1' }, { id: 1, text: 'Room 2' }], + { + id: 12, text: 'Room 12', color: '#444', parentId: 'board', }, + { + id: 21, text: 'Room 21', color: '#555', parentId: 'open', + }, +]; + +const createResourceLoader = async ( + fieldExpr: string, + dataSource: RawResourceData[], + label: string, + parentIdExpr?: string, +): Promise => { + const loader = new ResourceLoader({ + fieldExpr, + dataSource, + label, + parentIdExpr, + }); + + await loader.load(); + + return loader; }; +const createHierarchicalRoomResource = (): Promise => createResourceLoader( + 'roomId', + roomHierarchyData, + 'Room', + 'parentId', +); + +const createGroupLeaf = ( + groupIndex: number, + grouped: GroupLeaf['grouped'], +): GroupLeaf => ({ + groupIndex, + grouped, + resourceText: '', + resourceIndex: '', + children: [], +}); + +const groupsLeafs: GroupLeaf[] = [ + createGroupLeaf(0, { assigneeId: 1, roomId: 3 }), + createGroupLeaf(1, { assigneeId: 3, roomId: 4 }), + createGroupLeaf(2, { roomId: 0 }), + createGroupLeaf(3, { assigneeId: 0, roomId: 0 }), +]; + describe('groups utils', () => { + // eslint-disable-next-line @typescript-eslint/init-declarations + let resourceById: Record; + + beforeAll(async () => { + const [assigneeId, roomId] = await Promise.all([ + createResourceLoader('assigneeId', assigneeData, 'Assignee'), + createResourceLoader('roomId', roomData, 'Room'), + ]); + + resourceById = { assigneeId, roomId }; + }); + describe('groupResources', () => { it('should return empty tree for empty groups', () => { expect(groupResources(resourceById, [])).toEqual({ @@ -186,6 +255,94 @@ describe('groups utils', () => { ], }); }); + + it('should group hierarchical resource by parent-child tree', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + expect(groupResources({ roomId: hierarchicalRoom }, ['roomId'])).toEqual({ + groupTree: [ + { + resourceText: 'Board rooms', + resourceIndex: 'roomId', + grouped: { roomId: 'board' }, + children: [ + { + resourceText: 'Room 11', + resourceIndex: 'roomId', + grouped: { roomId: 11 }, + children: [], + }, + { + resourceText: 'Room 12', + resourceIndex: 'roomId', + grouped: { roomId: 12 }, + children: [], + }, + ], + }, + { + resourceText: 'Open spaces', + resourceIndex: 'roomId', + grouped: { roomId: 'open' }, + children: [ + { + resourceText: 'Room 21', + resourceIndex: 'roomId', + grouped: { roomId: 21 }, + children: [], + }, + ], + }, + ], + groupLeafs: [ + { + resourceText: 'Room 11', + resourceIndex: 'roomId', + grouped: { roomId: 11 }, + children: [], + groupIndex: 0, + }, + { + resourceText: 'Room 12', + resourceIndex: 'roomId', + grouped: { roomId: 12 }, + children: [], + groupIndex: 1, + }, + { + resourceText: 'Room 21', + resourceIndex: 'roomId', + grouped: { roomId: 21 }, + children: [], + groupIndex: 2, + }, + ], + }); + }); + + it('should use only leaf bands for hierarchical resource instead of cartesian product', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + const { groupLeafs } = groupResources({ roomId: hierarchicalRoom }, ['roomId']); + + expect(groupLeafs).toHaveLength(3); + expect(groupLeafs.map((leaf) => leaf.grouped.roomId)).toEqual([11, 12, 21]); + }); + + it('should combine hierarchical and flat resources', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + const { groupTree, groupLeafs } = groupResources({ + roomId: hierarchicalRoom, + assigneeId: resourceById.assigneeId, + }, ['roomId', 'assigneeId']); + + expect(groupLeafs).toHaveLength(6); + expect(groupLeafs[0].grouped).toEqual({ roomId: 11, assigneeId: 0 }); + expect(groupLeafs[1].grouped).toEqual({ roomId: 11, assigneeId: 1 }); + expect(groupTree[0].resourceText).toBe('Board rooms'); + expect(groupTree[0].children[0].children[0].resourceText).toBe('Samantha Bright'); + }); }); describe('getAllGroupValues', () => { @@ -246,12 +403,12 @@ describe('groups utils', () => { it('should return resources of groupIndex', () => { expect(getResourcesByGroupIndex(groupsLeafs, resourceById, 3)).toEqual([ { - items: [{ id: 0, text: 'Samantha Bright' }], - resourceIndex: 'assigneeId', + ...resourceById.assigneeId, + items: [{ id: 0, text: 'Samantha Bright', color: '#727bd2' }], }, { - items: [{ id: 0, text: 'Room 1' }], - resourceIndex: 'roomId', + ...resourceById.roomId, + items: [{ id: 0, text: 'Room 1', color: '#aaa' }], }, ]); }); diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts index 097c7fd2cf3a..3a472b6695a7 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts @@ -1,6 +1,89 @@ import type { ResourceLoader } from '../loader/resource_loader'; +import type { ResourceId } from '../loader/types'; +import type { ResourceHierarchyNode } from './hierarchy_tree_utils'; import type { GroupLeaf, GroupNode } from './types'; +const isVirtualRoot = (node: GroupNode): boolean => !node.resourceIndex; + +const createFlatResourceNodes = ( + resource: ResourceLoader, + parentGrouped: Record, +): GroupNode[] => resource.items.map((item) => ({ + resourceText: item.text, + resourceIndex: resource.resourceIndex, + grouped: { ...parentGrouped, [resource.resourceIndex]: item.id }, + children: [], +})); + +const hierarchyToGroupNodes = ( + hierarchyNodes: ResourceHierarchyNode[], + resourceIndex: string, + parentGrouped: Record, +): GroupNode[] => hierarchyNodes.map((node) => { + const grouped = { ...parentGrouped, [resourceIndex]: node.data.id }; + + return { + resourceText: node.data.text, + resourceIndex, + grouped, + children: hierarchyToGroupNodes(node.children, resourceIndex, grouped), + }; +}); + +const collectGroupLeaves = (nodes: GroupNode[]): GroupNode[] => { + const leaves: GroupNode[] = []; + + const walk = (node: GroupNode): void => { + if (node.children.length === 0) { + leaves.push(node); + return; + } + + node.children.forEach(walk); + }; + + nodes.forEach(walk); + + return leaves; +}; + +const mergeGroupedIntoTree = ( + node: GroupNode, + parentGrouped: Record, +): GroupNode => ({ + ...node, + grouped: { ...parentGrouped, ...node.grouped }, + children: node.children.map((child) => mergeGroupedIntoTree(child, parentGrouped)), +}); + +const createResourceNodes = ( + resource: ResourceLoader, + parentGrouped: Record, +): GroupNode[] => { + if (resource.hasHierarchy) { + return hierarchyToGroupNodes(resource.hierarchyTree, resource.resourceIndex, parentGrouped); + } + + return createFlatResourceNodes(resource, parentGrouped); +}; + +const attachResourceNodes = ( + leafs: GroupNode[], + nodes: GroupNode[], +): GroupNode[] => { + const nextLeafs: GroupNode[] = []; + + leafs.forEach((leaf) => { + leaf.children = nodes.map((node) => mergeGroupedIntoTree(node, leaf.grouped)); + + leaf.children.forEach((child) => { + nextLeafs.push(...collectGroupLeaves([child])); + }); + }); + + return nextLeafs; +}; + export const groupResources = (resourceById: Record, groups: string[]): { groupTree: GroupNode[]; groupLeafs: GroupLeaf[]; @@ -19,22 +102,15 @@ export const groupResources = (resourceById: Record, gro .filter((group) => resourceById[group]) .forEach((group) => { const resource = resourceById[group]; - const nodes = resource.items.map((item) => ({ - resourceText: item.text, - resourceIndex: resource.resourceIndex, - grouped: { [resource.resourceIndex]: item.id }, - children: [], - })); - const nextLeafs: GroupNode[] = []; - - leafs.forEach((leaf) => { - leaf.children = nodes.map((node) => ({ - ...node, - grouped: { ...node.grouped, ...leaf.grouped }, - })); - nextLeafs.push(...leaf.children); - }); - leafs = nextLeafs; + const nodes = createResourceNodes(resource, {}); + + if (isVirtualRoot(leafs[0])) { + head[0].children = nodes; + leafs = collectGroupLeaves(nodes); + return; + } + + leafs = attachResourceNodes(leafs, nodes); }); const groupLeafs = leafs.map((leaf, index) => ({ From 2c2ff777d67a87248e696a2a849bd7b3ccb119de Mon Sep 17 00:00:00 2001 From: Sergei Burkatskii <5622446+sjbur@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:07:22 +0200 Subject: [PATCH 2/3] test: add case for empty group tree in resource manager --- .../utils/resource_manager/group_utils.test.ts | 12 ++++++++++++ .../scheduler/utils/resource_manager/group_utils.ts | 12 +++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts index 659fcdc9667e..13e2670df2b2 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts @@ -343,6 +343,18 @@ describe('groups utils', () => { expect(groupTree[0].resourceText).toBe('Board rooms'); expect(groupTree[0].children[0].children[0].resourceText).toBe('Samantha Bright'); }); + + it('should return an empty group tree when an earlier resource in groups has an empty dataSource', async () => { + const emptyRoom = await createResourceLoader('roomId', [], 'Room'); + + expect(groupResources({ + roomId: emptyRoom, + assigneeId: resourceById.assigneeId, + }, ['roomId', 'assigneeId'])).toEqual({ + groupTree: [], + groupLeafs: [], + }); + }); }); describe('getAllGroupValues', () => { diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts index 3a472b6695a7..c1104f23dba1 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts @@ -7,11 +7,10 @@ const isVirtualRoot = (node: GroupNode): boolean => !node.resourceIndex; const createFlatResourceNodes = ( resource: ResourceLoader, - parentGrouped: Record, ): GroupNode[] => resource.items.map((item) => ({ resourceText: item.text, resourceIndex: resource.resourceIndex, - grouped: { ...parentGrouped, [resource.resourceIndex]: item.id }, + grouped: { [resource.resourceIndex]: item.id }, children: [], })); @@ -58,13 +57,12 @@ const mergeGroupedIntoTree = ( const createResourceNodes = ( resource: ResourceLoader, - parentGrouped: Record, ): GroupNode[] => { if (resource.hasHierarchy) { - return hierarchyToGroupNodes(resource.hierarchyTree, resource.resourceIndex, parentGrouped); + return hierarchyToGroupNodes(resource.hierarchyTree, resource.resourceIndex, {}); } - return createFlatResourceNodes(resource, parentGrouped); + return createFlatResourceNodes(resource); }; const attachResourceNodes = ( @@ -102,9 +100,9 @@ export const groupResources = (resourceById: Record, gro .filter((group) => resourceById[group]) .forEach((group) => { const resource = resourceById[group]; - const nodes = createResourceNodes(resource, {}); + const nodes = createResourceNodes(resource); - if (isVirtualRoot(leafs[0])) { + if (leafs.length > 0 && isVirtualRoot(leafs[0])) { head[0].children = nodes; leafs = collectGroupLeaves(nodes); return; From 8dcb55fe1e8cb0d912bb915a7481a01994a684fe Mon Sep 17 00:00:00 2001 From: Sergei Burkatskii <5622446+sjbur@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:18:36 +0200 Subject: [PATCH 3/3] fix: copilot review --- .../resource_manager/group_utils.test.ts | 7 ++++ .../utils/resource_manager/group_utils.ts | 33 +++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts index 13e2670df2b2..ebb8e4114718 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts @@ -109,6 +109,13 @@ describe('groups utils', () => { }); }); + it('should return empty tree when groups only contains keys missing from resourceById', () => { + expect(groupResources(resourceById, ['unknownId'])).toEqual({ + groupTree: [], + groupLeafs: [], + }); + }); + it('should group by one group', () => { expect(groupResources(resourceById, ['roomId'])).toEqual({ groupTree: [ diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts index c1104f23dba1..03b163f62b28 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts @@ -86,30 +86,35 @@ export const groupResources = (resourceById: Record, gro groupTree: GroupNode[]; groupLeafs: GroupLeaf[]; } => { - if (!groups.length || Object.keys(resourceById).length === 0) { + const validGroups = groups.filter((group) => resourceById[group]); + + if (!validGroups.length) { return { groupTree: [], groupLeafs: [], }; } - const head: GroupNode[] = [{} as GroupNode]; + const head: GroupNode[] = [{ + resourceText: '', + resourceIndex: '', + grouped: {}, + children: [], + }]; let leafs: GroupNode[] = head; - groups - .filter((group) => resourceById[group]) - .forEach((group) => { - const resource = resourceById[group]; - const nodes = createResourceNodes(resource); + validGroups.forEach((group) => { + const resource = resourceById[group]; + const nodes = createResourceNodes(resource); - if (leafs.length > 0 && isVirtualRoot(leafs[0])) { - head[0].children = nodes; - leafs = collectGroupLeaves(nodes); - return; - } + if (leafs.length > 0 && isVirtualRoot(leafs[0])) { + head[0].children = nodes; + leafs = collectGroupLeaves(nodes); + return; + } - leafs = attachResourceNodes(leafs, nodes); - }); + leafs = attachResourceNodes(leafs, nodes); + }); const groupLeafs = leafs.map((leaf, index) => ({ ...leaf,