diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table.tsx index ee24981fbff7..e6b102bc77b1 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table.tsx @@ -35,6 +35,7 @@ export class DateTable extends InfernoWrapperComponent { dataCellTemplate, groupOrientation, addVerticalSizesClassToRows, + rowHeights, ...restProps } = this.props; const classes = addDateTableClass ? 'dx-scheduler-date-table' : undefined; @@ -67,6 +68,7 @@ export class DateTable extends InfernoWrapperComponent { rightVirtualCellWidth={rightVirtualCellWidth} groupOrientation={groupOrientation} addVerticalSizesClassToRows={addVerticalSizesClassToRows} + rowHeights={rowHeights} topVirtualRowHeight={DateTableBodyDefaultProps.topVirtualRowHeight} bottomVirtualRowHeight={DateTableBodyDefaultProps.bottomVirtualRowHeight} addDateTableClass={DateTableBodyDefaultProps.addDateTableClass} diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.test.tsx new file mode 100644 index 000000000000..84a2d7918ce0 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.test.tsx @@ -0,0 +1,117 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import type { CellTemplateProps } from '../types'; +import { DateTableBody } from './date_table_body'; +import { Row } from './row'; + +interface RenderUtilsMock { + renderUtils: { + addHeightToStyle: ( + height: number | undefined, + styles?: Record, + ) => Record; + }; +} + +jest.mock('../../utils/index', (): RenderUtilsMock => ({ + renderUtils: { + addHeightToStyle: ( + height: number | undefined, + styles: Record = {}, + ): Record => (height === undefined ? styles : { ...styles, height }), + }, +})); + +const viewContext = { + view: { + type: 'day', + }, + crossScrollingEnabled: false, +} as const; + +const CellTemplate = (): JSX.Element => ; + +interface VirtualNodeLike { + type?: unknown; + props?: { + children?: unknown; + styles?: unknown; + }; + children?: unknown; +} + +const toArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) { + return value; + } + + return value === undefined || value === null ? [] : [value]; +}; + +const getRowNodes = (node: unknown): VirtualNodeLike[] => { + if (typeof node !== 'object' || node === null) { + return []; + } + + const virtualNode = node as VirtualNodeLike; + const currentNode = virtualNode.type === Row ? [virtualNode] : []; + const children = [ + ...toArray(virtualNode.children), + ...toArray(virtualNode.props?.children), + ]; + + return [ + ...currentNode, + ...children.flatMap(getRowNodes), + ]; +}; + +const createCell = ( + key: number, +): CellTemplateProps => ({ + key, + startDate: new Date(2025, 0, 1), + endDate: new Date(2025, 0, 1, 0, 30), + index: key, + isFirstGroupCell: false, + isLastGroupCell: false, + isSelected: false, + isFocused: false, +}); + +describe('DateTableBody', () => { + it('should apply row heights', () => { + const component = new DateTableBody({ + viewContext, + viewData: { + groupedData: [{ + dateTable: [ + { key: 0, cells: [createCell(0)] }, + { key: 1, cells: [createCell(1)] }, + ], + groupIndex: 0, + key: '0', + }], + leftVirtualCellCount: 0, + rightVirtualCellCount: 0, + topVirtualRowCount: 0, + bottomVirtualRowCount: 0, + }, + cellTemplate: CellTemplate, + leftVirtualCellWidth: 0, + rightVirtualCellWidth: 0, + topVirtualRowHeight: 0, + bottomVirtualRowHeight: 0, + addDateTableClass: true, + addVerticalSizesClassToRows: true, + rowHeights: [120, 80], + }); + + const rows = getRowNodes(component.render()); + + expect(rows[0].props?.styles).toEqual({ height: 120 }); + expect(rows[1].props?.styles).toEqual({ height: 80 }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.tsx index e2c540d16fcb..4ad5b03788c4 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/date_table_body.tsx @@ -4,6 +4,7 @@ import { PublicTemplate } from '@ts/scheduler/r1/components/templates/index'; import { Fragment } from 'inferno'; import { combineClasses } from '../../../../core/r1/utils/render_utils'; +import { renderUtils } from '../../utils/index'; import { DATE_TABLE_ROW_CLASS } from '../const'; import type { CellTemplateProps, DefaultProps } from '../types'; import { AllDayPanelTableBody, AllDayPanelTableBodyDefaultProps } from './all_day_panel_table_body'; @@ -29,7 +30,9 @@ export class DateTableBody extends BaseInfernoComponent { addVerticalSizesClassToRows, cellTemplate, dataCellTemplate, + rowHeights, } = this.props; + let rowIndex = 0; const rowClasses = combineClasses({ [DATE_TABLE_ROW_CLASS]: true, 'dx-scheduler-cell-sizes-vertical': addVerticalSizesClassToRows, @@ -63,10 +66,18 @@ export class DateTableBody extends BaseInfernoComponent { dateTable.map(({ cells, key: rowKey, - }) => ( + }) => { + const rowHeight = rowHeights?.[rowIndex]; + const rowStyles = rowHeight === undefined + ? undefined + : renderUtils.addHeightToStyle(rowHeight); + rowIndex += 1; + + return ( { } as CellTemplateProps} />) } - )) + ); + }) } )) diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx index 46691e83d76d..bfe4fb8e4a7e 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx @@ -39,6 +39,7 @@ export class GroupPanel extends InfernoWrapperComponent { groupOrientation, groups, styles, + rowHeights, } = this.props; const isVerticalLayout = isVerticalGroupingApplied(groups.length, groupOrientation); @@ -53,6 +54,7 @@ export class GroupPanel extends InfernoWrapperComponent { groupPanelData={groupPanelData} elementRef={elementRef} styles={styles} + rowHeights={rowHeights} groups={GroupPanelDefaultProps.groups} groupOrientation={GroupPanelDefaultProps.groupOrientation} groupByDate={GroupPanelDefaultProps.groupByDate} 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..ecfa7c1393e4 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 @@ -11,6 +11,7 @@ export interface GroupPanelBaseProps extends groupPanelData: GroupPanelData; groupByDate: boolean; height?: number; + rowHeights?: number[]; resourceCellTemplate?: JSXTemplate; } @@ -43,6 +44,7 @@ export const GroupPanelCellDefaultProps = { export interface GroupPanelRowProps extends PropsWithClassName { groupItems: GroupRenderItem[]; + height?: number; cellTemplate?: JSXTemplate; } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx index 07ed79cd4b09..fe2749291431 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx @@ -14,6 +14,7 @@ export class GroupPanelVertical extends BaseInfernoComponent { resourceCellTemplate, height, styles, + rowHeights, } = this.props; const style = normalizeStyles(renderUtils.addHeightToStyle(height, styles)); @@ -26,9 +27,10 @@ export class GroupPanelVertical extends BaseInfernoComponent {
{ groupPanelData.groupPanelItems - .map((group) => ) } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx new file mode 100644 index 000000000000..5bf6832f691b --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx @@ -0,0 +1,48 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import { GroupPanelVerticalRow } from './group_panel_vertical_row'; + +interface RenderUtilsMock { + renderUtils: { + addHeightToStyle: ( + height: number | undefined, + styles?: Record, + ) => Record; + }; +} + +jest.mock('../../utils/index', (): RenderUtilsMock => ({ + renderUtils: { + addHeightToStyle: ( + height: number | undefined, + styles: Record = {}, + ): Record => (height === undefined ? styles : { ...styles, height }), + }, +})); + +interface VirtualNodeLike { + props?: { + style?: unknown; + }; +} + +describe('GroupPanelVerticalRow', () => { + it('should apply row height', () => { + const component = new GroupPanelVerticalRow({ + groupItems: [{ + key: '0', + id: 0, + text: 'Group 0', + data: { id: 0 }, + resourceName: 'ownerId', + }], + height: 140, + className: '', + }); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.style).toEqual({ height: '140px' }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx index ec960d693ece..024e9f102f95 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx @@ -1,5 +1,6 @@ -import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/index'; +import { BaseInfernoComponent, normalizeStyles } from '@ts/core/r1/runtime/inferno/index'; +import { renderUtils } from '../../utils/index'; import type { GroupPanelRowProps } from './group_panel_props'; import { GroupPanelRowDefaultProps } from './group_panel_props'; import { GroupPanelVerticalCell } from './group_panel_vertical_cell'; @@ -9,11 +10,18 @@ export class GroupPanelVerticalRow extends BaseInfernoComponent +
{ groupItems.map(({ color, diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/layout_props.ts b/packages/devextreme/js/__internal/scheduler/r1/components/base/layout_props.ts index 5817b77be299..912de65887b0 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/layout_props.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/layout_props.ts @@ -15,6 +15,7 @@ export interface LayoutProps extends PropsWithViewContext { addDateTableClass: boolean; addVerticalSizesClassToRows: boolean; width?: number; + rowHeights?: number[]; dataCellTemplate?: JSXTemplate; } diff --git a/packages/devextreme/js/__internal/scheduler/shaders/current_time_shader_vertical.ts b/packages/devextreme/js/__internal/scheduler/shaders/current_time_shader_vertical.ts index f32ec8b2f58f..ce11d864784c 100644 --- a/packages/devextreme/js/__internal/scheduler/shaders/current_time_shader_vertical.ts +++ b/packages/devextreme/js/__internal/scheduler/shaders/current_time_shader_vertical.ts @@ -25,34 +25,47 @@ class VerticalCurrentTimeShader extends CurrentTimeShader { // eslint-disable-next-line @typescript-eslint/no-unused-vars renderShader(isHorizontalGroupedWorkSpace: boolean, groupCount: number, cellCount: number): void { let shaderHeight = this.getShaderHeight(); - const maxHeight = this.getShaderMaxHeight(); - const isSolidShader = shaderHeight > maxHeight; - if (shaderHeight > maxHeight) { - shaderHeight = maxHeight; - } + if (this.workSpace.isGroupedByDate()) { + const maxHeight = this.getShaderMaxHeight(); + const isSolidShader = shaderHeight > maxHeight; - setHeight(this.$shader, shaderHeight); + if (shaderHeight > maxHeight) { + shaderHeight = maxHeight; + } - if (this.workSpace.isGroupedByDate()) { + setHeight(this.$shader, shaderHeight); this.renderGroupedByDateShaderParts(groupCount, shaderHeight, maxHeight, isSolidShader); } else { - this.renderShaderParts(groupCount, shaderHeight, maxHeight, isSolidShader); + setHeight(this.$shader, Math.min(shaderHeight, this.getMaxShaderHeight(groupCount))); + this.renderShaderParts(groupCount, shaderHeight); } } + private getMaxShaderHeight(groupCount: number): number { + const effectiveGroupCount = groupCount || 1; + let maxHeight = 0; + + for (let i = 0; i < effectiveGroupCount; i += 1) { + maxHeight = Math.max(maxHeight, this.getShaderMaxHeight(i)); + } + + return maxHeight; + } + private renderShaderParts( groupCount: number, shaderHeight: number, - maxHeight: number, - isSolidShader: boolean, ): void { for (let i = 0; i < groupCount; i += 1) { + const maxHeight = this.getShaderMaxHeight(i); + const isSolidShader = shaderHeight > maxHeight; + const normalizedShaderHeight = isSolidShader ? maxHeight : shaderHeight; const shaderWidth = this.getShaderWidth(); - this.renderTopShader(this.$shader, shaderHeight, shaderWidth, i); + this.renderTopShader(this.$shader, normalizedShaderHeight, shaderWidth, i); if (!isSolidShader) { - this.renderBottomShader(this.$shader, maxHeight, shaderHeight, shaderWidth, i); + this.renderBottomShader(this.$shader, maxHeight, normalizedShaderHeight, shaderWidth, i); } this.renderAllDayShader(shaderWidth, i); @@ -152,8 +165,8 @@ class VerticalCurrentTimeShader extends CurrentTimeShader { return this.workSpace.getGroupedStrategy().getShaderHeight(); } - private getShaderMaxHeight(): number { - return this.workSpace.getGroupedStrategy().getShaderMaxHeight(); + private getShaderMaxHeight(groupIndex?: number): number { + return this.workSpace.getGroupedStrategy().getShaderMaxHeight(groupIndex); } private getShaderWidth(): number { diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.test.ts new file mode 100644 index 000000000000..28f619073f73 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.test.ts @@ -0,0 +1,101 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import type Scheduler from '@ts/scheduler/m_scheduler'; +import type { DOMMetaData } from '@ts/scheduler/types'; + +import type { CollectorCSS, RealSize } from '../steps/add_geometry/types'; +import { OptionManager, type WorkspaceLayoutData } from './option_manager'; + +const createDOMMetaData = ( + width: number, + height: number, +): DOMMetaData => ({ + dateTableCellsMeta: [[{ + top: 0, left: 0, width, height, + }]], + allDayPanelCellsMeta: [], +}); + +const createCollectorCSS = (): CollectorCSS => ({ + width: '12px', + height: '13px', + marginLeft: '2px', + marginRight: '3px', + marginTop: '4px', + marginBottom: '5px', +}); + +const viewOptions = new Map([ + ['allDayPanelMode', 'hidden'], + ['cellDuration', 30], + ['groupByDate', false], + ['hiddenWeekDays', []], + ['maxAppointmentsPerCell', 2], + ['showAllDayPanel', false], + ['snapToCellsMode', undefined], + ['startDayHour', 0], + ['endDayHour', 24], +]); + +const schedulerOptions = new Map([ + ['adaptivityEnabled', false], + ['rtlEnabled', false], +]); + +const createSchedulerStore = (workspace: unknown): Scheduler => ({ + currentView: { + type: 'day', + groupOrientation: 'vertical', + }, + resourceManager: { + groupCount: () => 2, + }, + getViewOffsetMs: () => 0, + getViewOption: (name: string) => viewOptions.get(name), + option: (name: string) => schedulerOptions.get(name), + isVirtualScrolling: () => false, + getWorkSpace: () => workspace, +}) as unknown as Scheduler; + +describe('OptionManager', () => { + it('should use injected layout data for geometry options', () => { + const workspace = { + getDateRange: (): Date[] => [new Date(2025, 0, 1), new Date(2025, 0, 2)], + getPanelDOMSize: jest.fn((): never => { throw new Error('Unexpected workspace size measure'); }), + getCollectorDimension: jest.fn((): never => { throw new Error('Unexpected workspace collector measure'); }), + getDOMElementsMetaData: jest.fn((): never => { throw new Error('Unexpected workspace metadata measure'); }), + }; + const layoutData: WorkspaceLayoutData = { + getPanelDOMSize: (): RealSize => ({ width: 500, height: 600 }), + getCollectorDimension: (): CollectorCSS => createCollectorCSS(), + getDOMElementsMetaData: (): DOMMetaData => createDOMMetaData(110, 40), + getGroupHeights: (): number[] => [100, 200], + }; + const optionManager = new OptionManager(createSchedulerStore(workspace), layoutData); + + const result = optionManager.getGeometryOptions('regularPanel'); + + expect(result.panelSize).toEqual({ width: 500, height: 600 }); + expect(result.cellSize).toEqual({ width: 110, height: 40 }); + expect(result.collectorSize).toEqual({ width: 20, height: 20 }); + expect(result.groupHeights).toEqual([100, 200]); + }); + + it('should use workspace layout data when injected layout data is not specified', () => { + const workspace = { + getDateRange: (): Date[] => [new Date(2025, 0, 1), new Date(2025, 0, 2)], + getPanelDOMSize: (): RealSize => ({ width: 300, height: 400 }), + getCollectorDimension: (): CollectorCSS => createCollectorCSS(), + getDOMElementsMetaData: (): DOMMetaData => createDOMMetaData(120, 50), + }; + const optionManager = new OptionManager(createSchedulerStore(workspace)); + + const result = optionManager.getGeometryOptions('regularPanel'); + + expect(result.panelSize).toEqual({ width: 300, height: 400 }); + expect(result.cellSize).toEqual({ width: 120, height: 50 }); + expect(result.collectorSize).toEqual({ width: 20, height: 20 }); + expect(result.groupHeights).toBeUndefined(); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.ts index b830eae445bb..5d5a38b38409 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/option_manager.ts @@ -1,5 +1,6 @@ import { Cache } from '../../../global_cache'; import type Scheduler from '../../../m_scheduler'; +import type { DOMMetaData } from '../../../types'; import type { CellInterval, CompareOptions, @@ -8,7 +9,12 @@ import type { PanelName, } from '../../types'; import type { CollectorOptions } from '../steps/add_collector/types'; -import type { GeometryOptions, VirtualCropOptions } from '../steps/add_geometry/types'; +import type { + CollectorCSS, + GeometryOptions, + RealSize, + VirtualCropOptions, +} from '../steps/add_geometry/types'; import { getGroupSize } from './get_group_size'; import { getMonthIntervals } from './get_month_intervals'; import { getPanelCollectorOptions } from './get_panel_collector_options'; @@ -16,6 +22,16 @@ import type { ViewModelOptions } from './get_view_model_options'; import { getViewModelOptions } from './get_view_model_options'; import { getWeekIntervals } from './get_week_intervals'; +export interface WorkspaceLayoutData { + getPanelDOMSize: (panelName: PanelName) => RealSize; + getCollectorDimension: ( + isCollectorCompact: boolean, + panelName: PanelName, + ) => CollectorCSS; + getDOMElementsMetaData: () => DOMMetaData; + getGroupHeights?: (panelName: PanelName) => number[] | undefined; +} + const getLayoutIntervals = ( compareOptions: CompareOptions, cellDurationMinutes: number, @@ -44,7 +60,10 @@ export class OptionManager { public readonly options: ViewModelOptions; - constructor(protected schedulerStore: Scheduler) { + constructor( + protected schedulerStore: Scheduler, + private readonly layoutData?: WorkspaceLayoutData, + ) { this.options = getViewModelOptions(schedulerStore); } @@ -54,14 +73,22 @@ export class OptionManager { collectorOptions: CollectorOptions; geometryOptions: GeometryOptions; } { - const workspace = this.schedulerStore.getWorkSpace(); - const panelDOMSize = workspace.getPanelDOMSize( + const layoutData: WorkspaceLayoutData = this.layoutData ?? this.schedulerStore.getWorkSpace(); + const panelDOMSize = layoutData.getPanelDOMSize( this.options.groupOrientation === 'vertical' ? 'regularPanel' : panelName, ); - - return this.cache.memo(`${panelDOMSize.width}.${panelDOMSize.height}.${panelName}`, () => { + const groupHeights = layoutData.getGroupHeights?.(panelName); + const groupHeightsKey = groupHeights?.join('.') ?? ''; + const cacheKey = [ + panelDOMSize.width, + panelDOMSize.height, + panelName, + groupHeightsKey, + ].join('.'); + + return this.cache.memo(cacheKey, () => { const { type, viewOffset, @@ -79,7 +106,7 @@ export class OptionManager { } = this.options; const viewOrientation = panelName === 'allDayPanel' ? 'horizontal' : nativeViewOrientation; const isCompactCollector = isAdaptivityEnabled || viewOrientation === 'vertical'; - const collectorCSS = workspace.getCollectorDimension(isCompactCollector, panelName); + const collectorCSS = layoutData.getCollectorDimension(isCompactCollector, panelName); const { allDayPanelCellSize, cellSize, @@ -92,7 +119,7 @@ export class OptionManager { viewOrientation, isAdaptivityEnabled, collectorCSS, - DOMMetaData: workspace.getDOMElementsMetaData(), + DOMMetaData: layoutData.getDOMElementsMetaData(), panelName, }); @@ -137,6 +164,7 @@ export class OptionManager { isAllDayPanel: panelName === 'allDayPanel', }), panelSize: panelDOMSize, + groupHeights, }; const collectorOptions: CollectorOptions = { cells, diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.test.ts new file mode 100644 index 000000000000..ad6399a13e3f --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from '@jest/globals'; + +import { aggregateMaxOverlap } from './aggregate_max_overlap'; + +interface GroupIndexItem { + groupIndex: number; + maxLevel: number; +} + +interface RowIndexItem { + rowIndex: number; + maxLevel: number; +} + +const getGroupIndex = (item: GroupIndexItem): number => Number(item.groupIndex); + +const getRowIndex = (item: RowIndexItem): number => Number(item.rowIndex); + +describe('aggregateMaxOverlap', () => { + it('should aggregate max overlap by key', () => { + const items: GroupIndexItem[] = [ + { groupIndex: 0, maxLevel: 1 }, + { groupIndex: 1, maxLevel: 3 }, + { groupIndex: 0, maxLevel: 2 }, + { groupIndex: 1, maxLevel: 1 }, + ]; + + const result = aggregateMaxOverlap(items, getGroupIndex); + + expect(result).toEqual([2, 3]); + }); + + it('should keep empty slots for missing keys', () => { + const items: RowIndexItem[] = [ + { rowIndex: 0, maxLevel: 2 }, + { rowIndex: 2, maxLevel: 4 }, + ]; + + const result = aggregateMaxOverlap(items, getRowIndex); + + expect(result).toEqual([2, 0, 4]); + }); + + it('should return empty array for empty items', () => { + const items: RowIndexItem[] = []; + + const result = aggregateMaxOverlap(items, getRowIndex); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.ts new file mode 100644 index 000000000000..05e90e29ea4b --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_collector/aggregate_max_overlap.ts @@ -0,0 +1,21 @@ +interface ItemWithMaxLevel { + maxLevel: number; +} + +export const aggregateMaxOverlap = ( + items: T[], + keyFn: (item: T) => number, +): number[] => { + const result = items.reduce((overlaps, item) => { + const key = keyFn(item); + overlaps[key] = Math.max(overlaps[key] ?? 0, item.maxLevel); + + return overlaps; + }, []); + + for (let i = 0; i < result.length; i += 1) { + result[i] ??= 0; + } + + return result; +}; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.test.ts index e729a5fcb202..941d6965cc13 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.test.ts @@ -113,4 +113,77 @@ describe('addGroupingOffset', () => { left: 1307, top: 89, columnIndex: 1, rowIndex: 1, groupIndex: 2, }); }); + + describe('with groupHeights', () => { + it('should use cumulative offset for vertical grouping', () => { + const entity = { left: 7, top: 9, groupIndex: 2 } as any; + + addGroupingOffset(entity, { + hasAllDayPanel: false, + groupCount: 3, + groupOrientation: 'vertical', + isGroupByDate: false, + isTimelineView: false, + allDayPanelCellSize: { width: 100, height: 80 }, + cellSize: { width: 100, height: 80 }, + groupSize: { width: 80, height: 800 }, + groupHeights: [100, 200, 300], + } as any); + + expect(entity).toEqual({ left: 7, top: 100 + 200 + 9, groupIndex: 2 }); + }); + + it('should use group size height when group height is not specified', () => { + const entity = { left: 7, top: 9, groupIndex: 3 } as any; + + addGroupingOffset(entity, { + hasAllDayPanel: false, + groupCount: 4, + groupOrientation: 'vertical', + isGroupByDate: false, + isTimelineView: false, + allDayPanelCellSize: { width: 100, height: 80 }, + cellSize: { width: 100, height: 80 }, + groupSize: { width: 80, height: 800 }, + groupHeights: [100, 200], + } as any); + + expect(entity).toEqual({ left: 7, top: 100 + 200 + 800 + 9, groupIndex: 3 }); + }); + + it('should use cumulative offset with all day panel', () => { + const entity = { left: 7, top: 9, groupIndex: 2 } as any; + + addGroupingOffset(entity, { + hasAllDayPanel: true, + groupCount: 3, + groupOrientation: 'vertical', + isGroupByDate: false, + isTimelineView: false, + allDayPanelCellSize: { width: 100, height: 50 }, + cellSize: { width: 100, height: 80 }, + groupSize: { width: 80, height: 800 }, + groupHeights: [100, 200, 300], + } as any); + + expect(entity).toEqual({ left: 7, top: 100 + 200 + 3 * 50 + 9, groupIndex: 2 }); + }); + + it('should not affect horizontal grouping', () => { + const entity = { left: 7, top: 9, groupIndex: 2 } as any; + + addGroupingOffset(entity, { + groupCount: 3, + groupOrientation: 'horizontal', + isGroupByDate: false, + isTimelineView: false, + allDayPanelCellSize: { width: 100, height: 80 }, + cellSize: { width: 100, height: 80 }, + groupSize: { width: 800, height: 80 }, + groupHeights: [100, 200, 300], + } as any); + + expect(entity).toEqual({ left: 2 * 800 + 7, top: 9, groupIndex: 2 }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.ts index a3ad48e09d20..460402820e93 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_grouping_offset.ts @@ -4,6 +4,26 @@ import type { GeometryOptions, } from './types'; +const getGroupHeight = ( + groupHeights: number[] | undefined, + groupIndex: number, + defaultHeight: number, +): number => groupHeights?.[groupIndex] ?? defaultHeight; + +const getCumulativeGroupOffset = ( + groupHeights: number[] | undefined, + groupIndex: number, + defaultHeight: number, +): number => { + let offset = 0; + + for (let i = 0; i < groupIndex; i += 1) { + offset += getGroupHeight(groupHeights, i, defaultHeight); + } + + return offset; +}; + export const addGroupingOffset = ( entity: GeometryMinimalEntity & Geometry, { @@ -15,6 +35,7 @@ export const addGroupingOffset = ( allDayPanelCellSize, cellSize, groupSize, + groupHeights, }: GeometryOptions, ): void => { if (groupCount) { @@ -30,7 +51,11 @@ export const addGroupingOffset = ( entity.left += entity.groupIndex * groupSize.width; // intervals before break; default: - entity.top += entity.groupIndex * groupSize.height + entity.top += getCumulativeGroupOffset( + groupHeights, + entity.groupIndex, + groupSize.height, + ) + (entity.groupIndex + Number(!entity.isAllDayPanelOccupied)) * Number(hasAllDayPanel) * allDayPanelCellSize.height; } diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/types.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/types.ts index a1c75e4c8ef8..205210228ef1 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/types.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/types.ts @@ -69,6 +69,7 @@ export interface GeometryOptions { collectorWithMarginsSize: RealSize; groupSize: RealSize; panelSize: RealSize; + groupHeights?: number[]; } export interface VirtualCropOptions { diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_config.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_config.ts index c04ad87d6861..c4b0ac2a89f7 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_config.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_config.ts @@ -12,6 +12,7 @@ export interface GroupedStrategyConfig { getIndicatorOffset: () => number; getIndicationHeight: () => number; getIndicationWidth: () => number; + getGroupHeights?: () => number[] | undefined; getScrollableScrollTop: () => number; getScrollableContentElement: () => Element; getElement: () => Element; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts index bee7cf9449e5..6c7c18e41408 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts @@ -201,7 +201,8 @@ class HorizontalGroupedStrategy { return this.config.getIndicationHeight(); } - getShaderMaxHeight(): number { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getShaderMaxHeight(groupIndex?: number): number { return (getBoundingRect(this.config.getScrollableContentElement()) as DOMRect).height; } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts new file mode 100644 index 000000000000..2913d192235d --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts @@ -0,0 +1,133 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; + +import type { GroupedStrategyConfig } from './work_space_grouped_strategy_config'; +import VerticalGroupedStrategy from './work_space_grouped_strategy_vertical'; + +jest.mock('@ts/scheduler/r1/utils/index', (): { + calculateDayDuration: (startDayHour: number, endDayHour: number) => number; + getVerticalGroupCountClass: () => undefined; +} => ({ + calculateDayDuration: (startDayHour: number, endDayHour: number): number => ( + endDayHour - startDayHour + ), + getVerticalGroupCountClass: (): undefined => undefined, +})); + +const createElement = ({ + height = 0, + left = 0, + right = 0, + width = 0, +}: { + height?: number; + left?: number; + right?: number; + width?: number; +}): Element => ({ + getBoundingClientRect: (): DOMRect => ({ + height, + left, + right, + width, + }) as DOMRect, +}) as unknown as Element; + +const createConfig = ( + options: Partial = {}, +): GroupedStrategyConfig => ({ + getRowCount: (): number => 48, + getCellCount: (): number => 7, + getGroupCount: (): number => 3, + getCellHeight: (): number => 10, + getCellWidth: (): number => 100, + getTimePanelWidth: (): number => 50, + getGroupTableWidth: (): number => 40, + getAllDayHeight: (): number => 20, + getWorkSpaceWidth: (): number => 700, + getWorkSpaceLeftOffset: (): number => 0, + getIndicatorOffset: (): number => 30, + getIndicationHeight: (): number => 70, + getIndicationWidth: (): number => 100, + getScrollableScrollTop: (): number => 10, + getScrollableContentElement: (): Element => createElement({ width: 800 }), + getElement: (): Element => createElement({ width: 900 }), + getHeaderPanelContainerElement: (): Element => createElement({ height: 20 }), + getCellIndexByCoordinates: (): number => 0, + supportAllDayRow: (): boolean => false, + isGroupedByDate: (): boolean => false, + showAllDayPanel: (): boolean => false, + startDayHour: (): number => 0, + endDayHour: (): number => 24, + hoursInterval: (): number => 0.5, + crossScrollingEnabled: (): boolean => false, + rtlEnabled: (): boolean => false, + getHeaderHeight: (): number => 5, + ...options, +}); + +describe('VerticalGroupedStrategy', () => { + it('should use uniform group heights when group heights are not specified', () => { + const strategy = new VerticalGroupedStrategy(createConfig()); + + const result = strategy.getGroupBoundsOffset(2, [ + createElement({ left: 10 }), + createElement({ right: 710 }), + ]); + + expect(result).toEqual({ + left: 10, + right: 710, + top: 2 * 480 + 20 + 5 - 10, + bottom: 2 * 480 + 20 + 5 - 10 + 480, + }); + }); + + it('should use cumulative group heights for group bounds', () => { + const strategy = new VerticalGroupedStrategy(createConfig({ + getGroupHeights: (): number[] => [100, 200, 300], + })); + + const result = strategy.getGroupBoundsOffset(2, [ + createElement({ left: 10 }), + createElement({ right: 710 }), + ]); + + expect(result).toEqual({ + left: 10, + right: 710, + top: 100 + 200 + 20 + 5 - 10, + bottom: 100 + 200 + 20 + 5 - 10 + 300, + }); + }); + + it('should use cumulative group heights for indicator offset', () => { + const strategy = new VerticalGroupedStrategy(createConfig({ + getGroupHeights: (): number[] => [100, 200, 300], + supportAllDayRow: (): boolean => true, + showAllDayPanel: (): boolean => true, + })); + const $indicator = { + css: jest.fn(), + } as unknown as dxElementWrapper; + + strategy.shiftIndicator($indicator, 15, 0, 2); + + expect($indicator.css).toHaveBeenCalledWith('left', 30 + 40); + expect($indicator.css).toHaveBeenCalledWith('top', 15 + 100 + 200 + 20 * 3); + }); + + it('should use group height for shader max height', () => { + const strategy = new VerticalGroupedStrategy(createConfig({ + getGroupHeights: (): number[] => [100, 200], + supportAllDayRow: (): boolean => true, + showAllDayPanel: (): boolean => true, + })); + + const result = strategy.getShaderMaxHeight(1); + + expect(result).toBe(200 + 10); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts index 4955162edf91..8018efecb51c 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts @@ -85,26 +85,46 @@ class VerticalGroupedStrategy { return this.config.getTimePanelWidth() + this.config.getGroupTableWidth(); } + private getDefaultGroupHeight(): number { + const startDayHour = this.config.startDayHour(); + const endDayHour = this.config.endDayHour(); + const hoursInterval = this.config.hoursInterval(); + + return (calculateDayDuration(startDayHour, endDayHour) / hoursInterval) + * this.config.getCellHeight(); + } + + private getGroupHeight(groupIndex: number): number { + return this.config.getGroupHeights?.()?.[groupIndex] ?? this.getDefaultGroupHeight(); + } + + private getCumulativeGroupOffset(groupIndex: number): number { + let offset = 0; + + for (let i = 0; i < groupIndex; i += 1) { + offset += this.getGroupHeight(i); + } + + return offset; + } + getGroupBoundsOffset(groupIndex: number, [$firstCell, $lastCell]: [Element, Element]) : GroupBoundsOffset { - return this.cache.memo(`groupBoundsOffset${groupIndex}`, () => { - const startDayHour = this.config.startDayHour(); - const endDayHour = this.config.endDayHour(); - const hoursInterval = this.config.hoursInterval(); + const groupHeightsKey = this.config.getGroupHeights?.()?.join('.') ?? ''; - const dayHeight = (calculateDayDuration(startDayHour, endDayHour) / hoursInterval) - * this.config.getCellHeight(); + return this.cache.memo(`groupBoundsOffset${groupIndex}.${groupHeightsKey}`, () => { + const groupHeight = this.getGroupHeight(groupIndex); const scrollTop = this.getScrollableScrollTop(); const headerRowHeight = getBoundingRect(this.config.getHeaderPanelContainerElement()).height; - let topOffset = groupIndex * dayHeight + headerRowHeight + let topOffset = this.getCumulativeGroupOffset(groupIndex) + headerRowHeight + this.config.getHeaderHeight() - scrollTop; if (this.config.showAllDayPanel() && this.config.supportAllDayRow()) { topOffset += this.config.getCellHeight() * (groupIndex + 1); } - const bottomOffset = topOffset + dayHeight; + const bottomOffset = topOffset + groupHeight; const { left } = $firstCell.getBoundingClientRect(); const { right } = $lastCell.getBoundingClientRect(); @@ -123,7 +143,7 @@ class VerticalGroupedStrategy { const offset = this.config.getIndicatorOffset(); const tableOffset = this.config.crossScrollingEnabled() ? 0 : this.config.getGroupTableWidth(); const horizontalOffset = rtlOffset ? rtlOffset - offset : offset; - let verticalOffset = this.config.getRowCount() * this.config.getCellHeight() * i; + let verticalOffset = this.getCumulativeGroupOffset(i); if (this.config.supportAllDayRow() && this.config.showAllDayPanel()) { verticalOffset += this.config.getAllDayHeight() * (i + 1); @@ -161,8 +181,8 @@ class VerticalGroupedStrategy { return height; } - getShaderMaxHeight(): number { - let height = this.config.getRowCount() * this.config.getCellHeight(); + getShaderMaxHeight(groupIndex = 0): number { + let height = this.getGroupHeight(groupIndex); if (this.config.supportAllDayRow() && this.config.showAllDayPanel()) { height += this.config.getCellHeight();