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..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 @@ -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', + }, + { + id: 12, text: 'Room 12', color: '#444', parentId: 'board', }, - roomId: { - resourceIndex: 'roomId', - items: [{ id: 0, text: 'Room 1' }, { id: 1, text: 'Room 2' }], + { + 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({ @@ -40,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: [ @@ -186,6 +262,106 @@ 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'); + }); + + 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', () => { @@ -246,12 +422,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..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 @@ -1,41 +1,120 @@ 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, +): GroupNode[] => resource.items.map((item) => ({ + resourceText: item.text, + resourceIndex: resource.resourceIndex, + grouped: { [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, +): GroupNode[] => { + if (resource.hasHierarchy) { + return hierarchyToGroupNodes(resource.hierarchyTree, resource.resourceIndex, {}); + } + + return createFlatResourceNodes(resource); +}; + +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[]; } => { - 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 = 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; - }); + 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; + } + + leafs = attachResourceNodes(leafs, nodes); + }); const groupLeafs = leafs.map((leaf, index) => ({ ...leaf,