diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace_hierarchical_resources.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace_hierarchical_resources.test.ts new file mode 100644 index 000000000000..b1e46efe1cd2 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace_hierarchical_resources.test.ts @@ -0,0 +1,134 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; +import SchedulerTimelineMonth from '../workspaces/m_timeline_month'; +import type SchedulerWorkSpace from '../workspaces/m_work_space'; + +type WorkspaceConstructor = new (container: Element, options?: any) => T; + +const createWorkspaceWithHierarchicalResources = ( + WorkSpace: WorkspaceConstructor, + currentView: string, +): { workspace: T; resourceManager: any } => { + const container = document.createElement('div'); + + // Hierarchical resource configuration + const roomTypeData = [ + { id: 1, text: 'Deluxe' }, + { id: 2, text: 'Super Deluxe' }, + { id: 3, text: 'Luxury' }, + ]; + + const roomData = [ + { id: 1, text: '101', roomTypeId: 1 }, + { id: 2, text: '102', roomTypeId: 1 }, + { id: 3, text: '103', roomTypeId: 1 }, + { id: 4, text: '104', roomTypeId: 2 }, + { id: 5, text: '105', roomTypeId: 2 }, + { id: 6, text: '106', roomTypeId: 2 }, + { id: 7, text: '107', roomTypeId: 3 }, + { id: 8, text: '108', roomTypeId: 3 }, + { id: 9, text: '109', roomTypeId: 3 }, + { id: 10, text: '110', roomTypeId: 3 }, + ]; + + const resourceConfig = [ + { + fieldExpr: 'roomTypeId', + dataSource: roomTypeData, + label: 'Room Type', + }, + { + fieldExpr: 'roomId', + dataSource: roomData, + parentResource: 'roomTypeId', + parentFieldExpr: 'roomTypeId', + label: 'Room', + }, + ]; + + const resourceManager = getResourceManagerMock(resourceConfig); + + const workspace = new WorkSpace(container, { + views: [currentView], + currentView, + currentDate: new Date(2020, 5, 26), + firstDayOfWeek: 0, + groups: ['roomTypeId', 'roomId'], + getResourceManager: () => resourceManager, + }); + + (workspace as any)._isVisible = () => true; + + return { workspace, resourceManager }; +}; + +describe('scheduler workspace hierarchical resources', () => { + it('should filter child resources correctly in groupResources', async () => { + const { resourceManager } = createWorkspaceWithHierarchicalResources( + SchedulerTimelineMonth, + 'timelineMonth', + ); + + // Load and set groups + await resourceManager.loadGroupResources(['roomTypeId', 'roomId']); + + const { groupTree, groupLeafs } = { + groupTree: resourceManager.groupsTree, + groupLeafs: resourceManager.groupsLeafs, + }; + + // Verify that groupsTree is properly filtered + expect(groupTree).toBeDefined(); + expect(groupTree.length).toBeGreaterThan(0); + + // Check that we have the right structure + groupTree.forEach((rootNode) => { + expect(rootNode.children).toBeDefined(); + }); + + // For now, just check that filtering is working at all + expect(groupLeafs.length).toBeGreaterThan(0); + }); + + it('should filter child resources correctly in getGroupPanelData', async () => { + const { workspace, resourceManager } = createWorkspaceWithHierarchicalResources( + SchedulerTimelineMonth, + 'timelineMonth', + ); + + // Load groups first + await resourceManager.loadGroupResources(['roomTypeId', 'roomId']); + + const { viewDataProvider } = workspace as any; + const options = { + getResourceManager: () => resourceManager, + }; + + const groupPanelData = viewDataProvider.getGroupPanelData(options); + + expect(groupPanelData).toBeDefined(); + expect(groupPanelData.groupPanelItems).toBeDefined(); + expect(groupPanelData.groupPanelItems.length).toBe(2); // roomTypeId and roomId + + const roomTypeItems = groupPanelData.groupPanelItems[0]; + const roomIdItems = groupPanelData.groupPanelItems[1]; + + // roomTypeId items should be unique with rowSpan: 3 items (Deluxe, Super Deluxe, Luxury) + expect(roomTypeItems.length).toBe(3); + // roomId items should be unique: 3 + 3 + 4 = 10 + expect(roomIdItems.length).toBe(10); + + // Check room types + expect(roomTypeItems[0].text).toBe('Deluxe'); + expect(roomTypeItems[1].text).toBe('Super Deluxe'); + expect(roomTypeItems[2].text).toBe('Luxury'); + + // Check rowSpan values + expect(roomTypeItems[0].rowSpan).toBe(3); // Deluxe has 3 children + expect(roomTypeItems[1].rowSpan).toBe(3); // Super Deluxe has 3 children + expect(roomTypeItems[2].rowSpan).toBe(4); // Luxury has 4 children + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts index e35c1bbdd35e..c6819fcbbe64 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts @@ -30,6 +30,7 @@ export interface GroupPanelCellProps extends PropsWithClassName { data: GroupItem; index: number; cellTemplate?: JSXTemplate; + rowSpan?: number; } export const GroupPanelCellDefaultProps = { diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx index 8db4f47d8e6b..2f7e735d1ca2 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx @@ -15,10 +15,17 @@ export class GroupPanelVerticalCell extends BaseInfernoComponent +
{ cellTemplate ? ) }
diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 5326f622e1d6..3191695c9a73 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -121,6 +121,7 @@ export interface GroupRenderItem extends GroupItem { resourceName: string; data: GroupItem; colSpan?: number; + rowSpan?: number; isFirstGroupCell?: boolean; isLastGroupCell?: boolean; } diff --git a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts index 910f50415fb4..5dc39526f246 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts @@ -28,6 +28,10 @@ export class ResourceLoader extends Loader { public icon?: string; + public parentResource?: string; + + public parentFieldExpr?: string; + constructor(config: ResourceConfig) { super(config, { pageSize: 0 }); const accessor = getAppointmentResourceAccessor(config); @@ -40,6 +44,8 @@ export class ResourceLoader extends Loader { this.resourceIndex = String(getResourceIndex(config)); this.resourceName = config.label; this.icon = config.icon; + this.parentResource = config.parentResource; + this.parentFieldExpr = config.parentFieldExpr; this.onInit(); } diff --git a/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts b/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts index 92c7317dc07f..39859aa5de3b 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts @@ -7,6 +7,8 @@ type ResourcesConfig = Required['resources']; export type ResourceConfig = ResourcesConfig[number] & { field?: string; // old notation of fieldExpr + parentResource?: string; // parent resource field name + parentFieldExpr?: string; // field in raw data that contains parent value }; export type RawResourceData = Record & { 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..c8809c41979d 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,14 @@ +import { isObject } from '@js/core/utils/type'; + +import type { GroupRenderItem } from '../../types'; import type { ResourceLoader } from '../loader/resource_loader'; +import type { ResourceData, ResourceId } from '../loader/types'; import type { GroupLeaf, GroupNode } from './types'; +const stringifyId = (id: ResourceId): string => (isObject(id) + ? JSON.stringify(id) + : String(id)); + export const groupResources = (resourceById: Record, groups: string[]): { groupTree: GroupNode[]; groupLeafs: GroupLeaf[]; @@ -19,19 +27,52 @@ 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[] = []; + + // Check if this resource has a parent resource for filtering + const { parentResource: parentResourceIndex, parentFieldExpr } = resource; leafs.forEach((leaf) => { + let itemsToUse = resource.items; + + // Filter child resource items based on parent resource value + if (parentResourceIndex && resourceById[parentResourceIndex] && parentFieldExpr) { + const parentValue = leaf.grouped[parentResourceIndex]; + + itemsToUse = resource.items.filter((item) => { + // Find raw data item by id + const rawDataItem = resource.data.find((rawItem) => { + const rawId = resource.dataAccessor.get('id', rawItem); + return rawId === item.id || (rawItem as Record).id === item.id; + }); + + if (!rawDataItem) return false; + + // Get parent field value from raw data + const rawDataRecord = rawDataItem as Record; + const itemParentValue = rawDataRecord[parentFieldExpr] + ?? resource.dataAccessor.get(parentFieldExpr, rawDataItem); + + // Compare with parent value from leaf + return itemParentValue === parentValue; + }); + } + + const nodes = itemsToUse.map((item) => ({ + resourceText: item.text, + resourceIndex: resource.resourceIndex, + grouped: { [resource.resourceIndex]: item.id }, + children: [], + })); + leaf.children = nodes.map((node) => ({ ...node, grouped: { ...node.grouped, ...leaf.grouped }, })); + }); + + // Prepare next level leafs + const nextLeafs: GroupNode[] = []; + leafs.forEach((leaf) => { nextLeafs.push(...leaf.children); }); leafs = nextLeafs; @@ -88,3 +129,96 @@ export const getResourcesByGroupIndex = ( items: resource.items.filter((item) => item.id === leafGroups[resourceIndex]), }) as ResourceLoader); }; + +const createGroupRenderItem = ( + item: ResourceData, + resourceIndex: string, + resourceName?: string, + rowSpan?: number, +): GroupRenderItem => { + const result: GroupRenderItem = { + id: item.id as number | string, + text: item.text, + color: item.color, + key: `${resourceIndex}_${stringifyId(item.id)}`, + resourceName: resourceName ?? resourceIndex, + data: { + id: item.id as number | string, + text: item.text, + color: item.color, + }, + }; + + if (rowSpan) { + result.rowSpan = rowSpan; + } + + return result; +}; + +export const createGroupPanelDataFromTree = ( + groupsTree: GroupNode[], + resourceById: Record, + groups: string[], + columnCountPerGroup: number, + groupByDate: boolean, + baseColSpan: number, +): { groupPanelItems: GroupRenderItem[][]; baseColSpan: number } => { + const groupPanelItems: GroupRenderItem[][] = []; + + groups.forEach((resourceIndex, groupIndex) => { + const resource = resourceById[resourceIndex]; + if (!resource) return; + + if (groupIndex === 0) { + // Parent level: create items with rowSpan based on child count + const items: GroupRenderItem[] = []; + const processedItems = new Set(); + + groupsTree.forEach((parentNode) => { + const itemId = parentNode.grouped[resourceIndex]; + if (itemId !== undefined && !processedItems.has(itemId)) { + processedItems.add(itemId); + const item = resource.items.find((i) => i.id === itemId); + if (item) { + // Calculate rowSpan as sum of children across all matching parent nodes + const rowSpan = groupsTree + .filter((node) => node.grouped[resourceIndex] === itemId) + .reduce((sum, node) => sum + (node.children?.length ?? 1), 0); + + items.push(createGroupRenderItem(item, resourceIndex, resource.resourceName, rowSpan)); + } + } + }); + + groupPanelItems.push(items); + } else { + // Child levels: collect all unique items + const uniqueItems = new Map(); + + const collectItems = (nodes: GroupNode[]): void => { + nodes.forEach((node) => { + const itemId = node.grouped[resourceIndex]; + if (itemId !== undefined && !uniqueItems.has(itemId)) { + const item = resource.items.find((i) => i.id === itemId); + if (item) { + uniqueItems.set( + itemId, + createGroupRenderItem(item, resourceIndex, resource.resourceName), + ); + } + } + + if (node.children) { + collectItems(node.children); + } + }); + }; + + collectItems(groupsTree); + groupPanelItems.push(Array.from(uniqueItems.values())); + } + }); + + return { groupPanelItems, baseColSpan }; +}; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts index 5f374abead1d..07ffeb426032 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts @@ -18,6 +18,7 @@ import type { ViewDataMap, ViewOptions, ViewType, } from '../../types'; +import { createGroupPanelDataFromTree } from '../../utils/resource_manager/group_utils'; import { DateHeaderDataGenerator } from './m_date_header_data_generator'; import { GroupedDataMapProvider } from './m_grouped_data_map_provider'; import { TimePanelDataGenerator } from './m_time_panel_data_generator'; @@ -167,7 +168,26 @@ export default class ViewDataProvider { getGroupPanelData(options) { const renderOptions = this._transformRenderOptions(options); - const groupResources = renderOptions.getResourceManager().groupResources(); + const resourceManager = renderOptions.getResourceManager(); + + // Check if we have hierarchical resources (any resource with parentResource) + const hasHierarchicalResources = Object.values(resourceManager.resourceById) + .some((resource) => resource.parentResource); + + if (hasHierarchicalResources && resourceManager.groupsTree?.length) { + const cellCount = this.getCellCount(renderOptions); + return createGroupPanelDataFromTree( + resourceManager.groupsTree, + resourceManager.resourceById, + resourceManager.groups, + cellCount, + renderOptions.isGroupedByDate, + renderOptions.isGroupedByDate ? 1 : cellCount, + ); + } + + // Fallback to original method + const groupResources = resourceManager.groupResources(); if (groupResources.length > 0) { const cellCount = this.getCellCount(renderOptions);