From 9025a004328d4581bcb9b3f791395b5fd185a14b Mon Sep 17 00:00:00 2001 From: Sergei Burkatskii Date: Fri, 19 Jun 2026 09:57:53 +0200 Subject: [PATCH 1/2] feat: create spike --- .../js/__internal/scheduler/m_scheduler.ts | 1 + .../scheduler/utils/options/constants.ts | 1 + .../view_model/appointments_layout_manager.ts | 108 ++++++++++++++++++ .../options/get_panel_collector_options.ts | 10 +- .../options/get_view_model_options.ts | 3 + .../options/option_manager.ts | 17 +++ .../steps/add_geometry/add_geometry.ts | 8 +- .../steps/add_geometry/add_grouping_offset.ts | 22 +++- .../add_geometry/get_appointment_geometry.ts | 9 +- .../steps/add_geometry/types.ts | 2 + .../scheduler/workspaces/m_work_space.ts | 59 ++++++++++ packages/devextreme/js/ui/scheduler.d.ts | 11 ++ 12 files changed, 243 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 9f6c5c7d607c..cbade3505bd1 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -568,6 +568,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { break; case 'dateSerializationFormat': break; + case 'autoHeight': case 'maxAppointmentsPerCell': this.repaint(); break; diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts index bb3e1a938478..e3e30f282eb1 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts @@ -51,6 +51,7 @@ export const DEFAULT_SCHEDULER_OPTIONS: Properties = { indicatorUpdateInterval: 300000, recurrenceEditMode: 'dialog', cellDuration: 30, + autoHeight: false, maxAppointmentsPerCell: 'auto', selectedCellData: [], groupByDate: false, diff --git a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts index 6144f11a012e..1d8c35da716b 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts @@ -5,6 +5,7 @@ import { filterAppointments } from './filtration/filter_appointments'; import { getOccurrences } from './filtration/get_occurrences'; import { generateAgendaViewModel } from './generate_view_model/generate_agenda_view_model'; import { generateGridViewModel, sortAppointments } from './generate_view_model/generate_grid_view_model'; +import { getDefaultAppointmentSize } from './generate_view_model/options/get_min_appointment_size'; import { OptionManager } from './generate_view_model/options/option_manager'; import type { RealSize } from './generate_view_model/steps/add_geometry/types'; import { getAgendaAppointmentInfo, getAppointmentInfo } from './get_appointment_info'; @@ -20,6 +21,57 @@ import type { UTCDatesBeforeSplit, } from './types'; +const computeAutoPerRowHeights = ( + sortedItems: SortedEntity[], + panelName: 'regularPanel' | 'allDayPanel', + minHeight: number, + baseCellHeight: number, + isMonthView: boolean, +): number[] => { + const maxLevelPerRow: Record = {}; + + for (const item of sortedItems) { + const inPanel = panelName === 'allDayPanel' ? item.isAllDayPanelOccupied : !item.isAllDayPanelOccupied; + if (inPanel) { + const rowKey = isMonthView ? item.rowIndex : item.groupIndex; + const prev = maxLevelPerRow[rowKey] ?? 0; + if (item.maxLevel > prev) { + maxLevelPerRow[rowKey] = item.maxLevel; + } + } + } + + const keys = Object.keys(maxLevelPerRow); + if (keys.length === 0) { + return []; + } + + const maxKey = Math.max(...keys.map(Number)); + const heights: number[] = Array(maxKey + 1).fill(baseCellHeight); + for (const [key, maxLevel] of Object.entries(maxLevelPerRow)) { + heights[Number(key)] = Math.max(baseCellHeight, maxLevel * minHeight); + } + return heights; +}; + +const computeAllDayAutoHeight = ( + sortedItems: SortedEntity[], + minHeight: number, + baseCellHeight: number, +): number => { + let maxLevel = 0; + + for (const item of sortedItems) { + if (item.isAllDayPanelOccupied) { + maxLevel = Math.max(maxLevel, item.maxLevel); + } + } + + return maxLevel > 0 + ? Math.max(baseCellHeight, maxLevel * minHeight) + : baseCellHeight; +}; + class AppointmentLayoutManager { private preparedItems: MinimalAppointmentEntity[] = []; @@ -77,6 +129,62 @@ class AppointmentLayoutManager { this._sortedItems = sortAppointments(optionManager, this._filteredItems); + const { + autoHeight, + viewOrientation, + isTimelineView, + isMonthView, + isAdaptivityEnabled, + hasAllDayPanel, + } = optionManager.options; + + const isAutoHeightApplicable = autoHeight && (isTimelineView || isMonthView); + + if (isAutoHeightApplicable) { + const workspace = this.schedulerStore.getWorkSpace(); + const baseCellHeight = Math.max(workspace.getCellHeight(), 1); + const minAppointmentHeight = getDefaultAppointmentSize({ + isTimelineView, + isAdaptivityEnabled, + viewOrientation, + }).height; + + const autoRowHeights = computeAutoPerRowHeights( + this._sortedItems, + 'regularPanel', + minAppointmentHeight, + baseCellHeight, + isMonthView, + ); + + if (autoRowHeights.length) { + workspace.setAutoRowHeights(autoRowHeights); + optionManager.setAutoRowHeights(autoRowHeights); + } + + if (hasAllDayPanel) { + const allDayBaseCellHeight = Math.max(workspace.getAllDayHeight(), 1); + const allDayMinAppointmentHeight = getDefaultAppointmentSize({ + isTimelineView: false, + isAdaptivityEnabled, + viewOrientation: 'horizontal', + }).height; + const requiredAllDayHeight = computeAllDayAutoHeight( + this._sortedItems, + allDayMinAppointmentHeight, + allDayBaseCellHeight, + ); + + workspace.setAutoAllDayRowHeight(requiredAllDayHeight); + } + + optionManager.clearCache(); + } else { + const workspace = this.schedulerStore.getWorkSpace(); + workspace.clearAutoRowHeight(); + optionManager.resetAutoRowHeights(); + } + const viewModel = generateGridViewModel( this.schedulerStore, optionManager, diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_panel_collector_options.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_panel_collector_options.ts index ae49b77f5d15..838c99f9c435 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_panel_collector_options.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_panel_collector_options.ts @@ -17,6 +17,7 @@ const MIN_LEVEL_VERTICAL_VIEW = 1; export const getPanelCollectorOptions = (schedulerStore: Scheduler, { alwaysReserveSpaceForCollector, isTimelineView, + isMonthView, viewOrientation, isAdaptivityEnabled, collectorCSS, @@ -26,6 +27,7 @@ export const getPanelCollectorOptions = (schedulerStore: Scheduler, { DOMMetaData: DOMMetaData; alwaysReserveSpaceForCollector: boolean; isTimelineView: boolean; + isMonthView: boolean; viewOrientation: Orientation; isAdaptivityEnabled: boolean; collectorCSS: CollectorCSS; @@ -52,7 +54,13 @@ export const getPanelCollectorOptions = (schedulerStore: Scheduler, { width: cellDOM.width ?? 0, height: cellDOM.height ?? 0, }; - const maxAppointmentsPerCell = schedulerStore.getViewOption('maxAppointmentsPerCell'); + const isAutoHeight = Boolean( + schedulerStore.getViewOption('autoHeight') ?? schedulerStore.option('autoHeight'), + ); + const isAutoHeightApplicable = isAutoHeight && (isTimelineView || isMonthView); + const maxAppointmentsPerCell = isAutoHeightApplicable + ? 'unlimited' + : schedulerStore.getViewOption('maxAppointmentsPerCell'); const collectorSizes = maxAppointmentsPerCell === 'unlimited' && !alwaysReserveSpaceForCollector ? UNLIMITED_COLLECTOR_SIZES : getCollectorSize( diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts index 5154129aeee5..a2020fe80efd 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts @@ -56,6 +56,7 @@ export interface ViewModelOptions { isAdaptivityEnabled: boolean; cellDurationMinutes: number; isVirtualScrolling: boolean; + autoHeight: boolean; } export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions => { @@ -76,6 +77,7 @@ export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions } = configByView[type]; const isRTLEnabled = Boolean(schedulerStore.option('rtlEnabled')); const isAdaptivityEnabled = Boolean(schedulerStore.option('adaptivityEnabled')); + const autoHeight = Boolean(schedulerStore.getViewOption('autoHeight') ?? schedulerStore.option('autoHeight')); const cellDurationMinutes = schedulerStore.getViewOption('cellDuration'); const allDayPanelMode = schedulerStore.getViewOption('allDayPanelMode'); const snapToCellsMode = schedulerStore.getViewOption('snapToCellsMode'); @@ -95,6 +97,7 @@ export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions viewOrientation, isRTLEnabled, isAdaptivityEnabled, + autoHeight, cellDurationMinutes, hasAllDayPanel: showAllDayPanel && allDayPanelMode !== 'hidden' && viewOrientation === 'vertical', isVirtualScrolling, 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..f72b37f9938b 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 @@ -44,10 +44,20 @@ export class OptionManager { public readonly options: ViewModelOptions; + private autoRowHeightsData: number[] | undefined; + constructor(protected schedulerStore: Scheduler) { this.options = getViewModelOptions(schedulerStore); } + setAutoRowHeights(heights: number[]): void { + this.autoRowHeightsData = heights.length ? heights : undefined; + } + + resetAutoRowHeights(): void { + this.autoRowHeightsData = undefined; + } + protected getPanelOptions(panelName: PanelName): { splitIntervals: DateInterval[]; cells: CellInterval[]; @@ -89,6 +99,7 @@ export class OptionManager { } = getPanelCollectorOptions(this.schedulerStore, { alwaysReserveSpaceForCollector: type === 'month', isTimelineView, + isMonthView, viewOrientation, isAdaptivityEnabled, collectorCSS, @@ -120,6 +131,7 @@ export class OptionManager { groupOrientation, isGroupByDate, isTimelineView, + isMonthView, isRTLEnabled, isAdaptivityEnabled, allDayPanelCellSize, @@ -137,6 +149,7 @@ export class OptionManager { isAllDayPanel: panelName === 'allDayPanel', }), panelSize: panelDOMSize, + autoRowHeights: this.autoRowHeightsData, }; const collectorOptions: CollectorOptions = { cells, @@ -155,6 +168,10 @@ export class OptionManager { }); } + clearCache(): void { + this.cache.clear(); + } + getSplitIntervals(panelName: PanelName): DateInterval[] { return this.getPanelOptions(panelName).splitIntervals; } diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_geometry.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_geometry.ts index 1c41758d046f..abc8326baaac 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_geometry.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/add_geometry.ts @@ -1,7 +1,7 @@ import type { AppointmentCollectorWithGeometry } from '../../../types'; import { addAdaptivityGeometryInsideInterval } from './add_adaptivity_geometry_inside_interval'; import { addGeometryInsideInterval } from './add_geometry_inside_interval'; -import { addGroupingOffset } from './add_grouping_offset'; +import { addGroupingOffset, getCumulativeRowOffset } from './add_grouping_offset'; import type { Geometry, GeometryMinimalEntity, @@ -20,11 +20,13 @@ const RTLSwap = ( const addPanelOffset = ( entity: T, - { cellSize, viewOrientation, isTimelineView }: GeometryOptions, + { + cellSize, viewOrientation, isTimelineView, autoRowHeights, + }: GeometryOptions, ): void => { switch (true) { case viewOrientation === 'horizontal' && !isTimelineView: // month - entity.top += entity.rowIndex * cellSize.height; + entity.top += getCumulativeRowOffset(autoRowHeights, entity.rowIndex, cellSize.height); break; case viewOrientation === 'horizontal' && isTimelineView: // timelineX case viewOrientation === 'vertical': // day, week, workWeek 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..b13b2d035176 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,14 @@ import type { GeometryOptions, } from './types'; +export const getCumulativeRowOffset = ( + heights: number[] | undefined, + index: number, + uniformSize: number, +): number => (heights?.length + ? heights.slice(0, index).reduce((sum, height) => sum + height, 0) + : index * uniformSize); + export const addGroupingOffset = ( entity: GeometryMinimalEntity & Geometry, { @@ -15,6 +23,7 @@ export const addGroupingOffset = ( allDayPanelCellSize, cellSize, groupSize, + autoRowHeights, }: GeometryOptions, ): void => { if (groupCount) { @@ -29,10 +38,17 @@ export const addGroupingOffset = ( case groupOrientation === 'horizontal': entity.left += entity.groupIndex * groupSize.width; // intervals before break; - default: - entity.top += entity.groupIndex * groupSize.height - + (entity.groupIndex + Number(!entity.isAllDayPanelOccupied)) + default: { + const groupTopOffset = getCumulativeRowOffset( + autoRowHeights, + entity.groupIndex, + groupSize.height, + ); + + entity.top += groupTopOffset + + (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/get_appointment_geometry.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/get_appointment_geometry.ts index fdea4ef82c3e..73501840482c 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/get_appointment_geometry.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/add_geometry/get_appointment_geometry.ts @@ -16,10 +16,17 @@ export const getAppointmentGeometry = ( cellSize, collectorWithMarginsSize, viewOrientation, + isMonthView, cells, + autoRowHeights, }: GeometryOptions, ): Geometry => { - const cellAbstractSize = getAbstractSizeByViewOrientation(cellSize, viewOrientation); + const rowKey = isMonthView ? entity.rowIndex : entity.groupIndex; + const effectiveCellSize = autoRowHeights?.length + ? { ...cellSize, height: autoRowHeights[rowKey] ?? cellSize.height } + : cellSize; + + const cellAbstractSize = getAbstractSizeByViewOrientation(effectiveCellSize, viewOrientation); const collectorFullAbstractSize = getAbstractSizeByViewOrientation( collectorWithMarginsSize, viewOrientation, 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..72b243ac8ec4 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 @@ -59,6 +59,7 @@ export interface GeometryOptions { isGroupByDate: boolean; hasAllDayPanel: boolean; isTimelineView: boolean; + isMonthView: boolean; isRTLEnabled: boolean; isAdaptivityEnabled: boolean; collectorPosition: 'start' | 'end'; @@ -69,6 +70,7 @@ export interface GeometryOptions { collectorWithMarginsSize: RealSize; groupSize: RealSize; panelSize: RealSize; + autoRowHeights?: number[]; } export interface VirtualCropOptions { diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 247c2c94862d..50495517dce9 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1686,6 +1686,65 @@ class SchedulerWorkSpace extends Widget { ); } + setAutoRowHeights(heights: number[]): void { + const $dateTable = this.getDateTable(); + if (!$dateTable?.length) return; + + let totalHeight = 0; + const $rows = $dateTable.find('tr').not(`.${VIRTUAL_ROW_CLASS}`); + $rows.each((index: number, row: Element) => { + if (index < heights.length) { + const h = heights[index]; + (row as HTMLElement).style.height = `${h}px`; + totalHeight += h; + } else { + totalHeight += (row as HTMLElement).offsetHeight; + } + }); + + ($dateTable[0] as HTMLElement).style.height = `${totalHeight}px`; + + if (this.$timePanel?.length) { + this.$timePanel.find('tr').each((index: number, row: Element) => { + if (index < heights.length) { + (row as HTMLElement).style.height = `${heights[index]}px`; + } + }); + } + + this.cache.delete('cellElementsMeta'); + this.cache.delete('regularPanelSize'); + } + + setAutoAllDayRowHeight(height: number): void { + if (this.$allDayPanel?.length) { + this.$allDayPanel.find('tr').css('height', `${height}px`); + } + + this.cache.delete('cellElementsMeta'); + this.cache.delete('allDayPanelSize'); + } + + clearAutoRowHeight(): void { + const $dateTable = this.getDateTable(); + if ($dateTable?.length) { + $dateTable.find('tr').css('height', ''); + ($dateTable[0] as HTMLElement).style.height = ''; + } + + if (this.$timePanel?.length) { + this.$timePanel.find('tr').css('height', ''); + } + + if (this.$allDayPanel?.length) { + this.$allDayPanel.find('tr').css('height', ''); + } + + this.cache.delete('cellElementsMeta'); + this.cache.delete('regularPanelSize'); + this.cache.delete('allDayPanelSize'); + } + getIndicatorOffset(): number { return 0; } diff --git a/packages/devextreme/js/ui/scheduler.d.ts b/packages/devextreme/js/ui/scheduler.d.ts index e7bcc32063fd..2cba76fa4b7b 100644 --- a/packages/devextreme/js/ui/scheduler.d.ts +++ b/packages/devextreme/js/ui/scheduler.d.ts @@ -734,6 +734,12 @@ export interface dxSchedulerOptions extends WidgetOptions { * @public */ max?: Date | number | string | undefined; + /** + * @docid + * @default false + * @public + */ + autoHeight?: boolean; /** * @docid * @default "auto" @@ -1138,6 +1144,11 @@ export interface dxSchedulerOptions extends WidgetOptions { * @default 1 */ intervalCount?: number; + /** + * @docid + * @default false + */ + autoHeight?: boolean; /** * @docid * @default "auto" From e0ebfe5ee22ad703a82898f0110cd22b97525f5f Mon Sep 17 00:00:00 2001 From: Sergei Burkatskii Date: Fri, 19 Jun 2026 10:08:31 +0200 Subject: [PATCH 2/2] fix: fix merge conflicts --- .../js/__internal/scheduler/workspaces/work_space.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts index 39516cb02a0f..102e0120681e 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts @@ -1843,6 +1843,8 @@ class SchedulerWorkSpace extends Widget { } else { totalHeight += (row as HTMLElement).offsetHeight; } + + return true; }); ($dateTable[0] as HTMLElement).style.height = `${totalHeight}px`; @@ -1852,6 +1854,8 @@ class SchedulerWorkSpace extends Widget { if (index < heights.length) { (row as HTMLElement).style.height = `${heights[index]}px`; } + + return true; }); }