diff --git a/apps/react-storybook/stories/scheduler/SchedulerSnapToCellsMode.stories.tsx b/apps/react-storybook/stories/scheduler/SchedulerSnapToCellsMode.stories.tsx new file mode 100644 index 000000000000..e3450c180f40 --- /dev/null +++ b/apps/react-storybook/stories/scheduler/SchedulerSnapToCellsMode.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import dxScheduler from "devextreme/ui/scheduler"; +import { wrapDxWithReact } from "../utils"; +import { resources } from "./data"; + +const Scheduler = wrapDxWithReact(dxScheduler); + +const data = [ + { + text: '1 minute', + roomId: 1, + assigneeId: [1], + priorityId: 1, + startDate: new Date(2026, 2, 15, 10, 0), + endDate: new Date(2026, 2, 15, 10, 1) + }, + { + text: '5 minutes', + roomId: 1, + assigneeId: [2], + priorityId: 1, + startDate: new Date(2026, 2, 16, 10, 0), + endDate: new Date(2026, 2, 16, 10, 5) + }, + { + text: '15 minutes', + roomId: 2, + assigneeId: [3], + priorityId: 2, + startDate: new Date(2026, 2, 17, 10, 0), + endDate: new Date(2026, 2, 17, 10, 15) + }, + { + text: '30 minutes', + roomId: 2, + assigneeId: [1], + priorityId: 2, + startDate: new Date(2026, 2, 18, 10, 0), + endDate: new Date(2026, 2, 18, 10, 30) + }, + { + text: '46 minutes', + roomId: 3, + assigneeId: [2], + priorityId: 1, + startDate: new Date(2026, 2, 19, 10, 0), + endDate: new Date(2026, 2, 19, 10, 46) + }, + { + text: '1 hour', + roomId: 2, + assigneeId: [4], + priorityId: 1, + startDate: new Date(2026, 2, 20, 10, 0), + endDate: new Date(2026, 2, 20, 11, 0) + }, +]; + +const viewNames = ['day', 'week', 'workWeek', 'month', 'agenda', 'timelineDay', 'timelineWeek', 'timelineWorkWeek', 'timelineMonth']; + +const meta: Meta = { + title: 'Components/Scheduler/SnapToCellsMode', + component: Scheduler, + parameters: { layout: 'padded' }, +}; +export default meta; + +type Story = StoryObj; + +export const Overview: Story = { + args: { + height: 600, + views: viewNames, + currentView: 'week', + currentDate: new Date(2026, 2, 15), + startDayHour: 9, + endDayHour: 22, + dataSource: data, + resources, + snapToCellsMode: 'auto', + }, + argTypes: { + height: { control: 'number' }, + views: { control: 'object' }, + snapToCellsMode: { control: 'select', options: ['always', 'auto', 'never'] }, + currentView: { control: 'select', options: viewNames }, + }, +}; diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/snap_to_cells_mode.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/snap_to_cells_mode.test.ts new file mode 100644 index 000000000000..2663d5e2c533 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/snap_to_cells_mode.test.ts @@ -0,0 +1,86 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; + +import { createScheduler } from './__mock__/create_scheduler'; +import { + DEFAULT_CELL_HEIGHT, + setupSchedulerTestEnvironment, +} from './__mock__/m_mock_scheduler'; + +describe('snapToCellsMode', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('default snapToCellsMode on day view', async () => { + const { POM } = await createScheduler({ + width: 800, + height: 600, + views: ['day'], + currentView: 'day', + currentDate: new Date(2026, 2, 15), + cellDuration: 30, + startDayHour: 9, + endDayHour: 18, + dataSource: [{ + text: 'short', + startDate: new Date(2026, 2, 15, 10, 0), + endDate: new Date(2026, 2, 15, 10, 10), + }], + }); + + const appH = POM.getAppointment('short').getGeometry().height; + + expect(appH).toBeLessThan(DEFAULT_CELL_HEIGHT * 0.45); + }); + + it('root snapToCellsMode always overrides default on day view', async () => { + const { POM } = await createScheduler({ + width: 800, + height: 600, + views: ['day'], + currentView: 'day', + currentDate: new Date(2026, 2, 15), + cellDuration: 30, + startDayHour: 9, + endDayHour: 18, + dataSource: [{ + text: 'short', + startDate: new Date(2026, 2, 15, 10, 0), + endDate: new Date(2026, 2, 15, 10, 10), + }], + snapToCellsMode: 'always', + }); + + const appH = POM.getAppointment('short').getGeometry().height; + + expect(appH).toBeGreaterThan(DEFAULT_CELL_HEIGHT * 0.85); + }); + + it('views[].snapToCellsMode always overrides default on day view', async () => { + const { POM } = await createScheduler({ + width: 800, + height: 600, + views: [{ type: 'day', snapToCellsMode: 'always' }], + currentView: 'day', + currentDate: new Date(2026, 2, 15), + cellDuration: 30, + startDayHour: 9, + endDayHour: 18, + dataSource: [{ + text: 'short', + startDate: new Date(2026, 2, 15, 10, 0), + endDate: new Date(2026, 2, 15, 10, 10), + }], + }); + + const appH = POM.getAppointment('short').getGeometry().height; + + expect(appH).toBeGreaterThan(DEFAULT_CELL_HEIGHT * 0.85); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts index b1fbdaab59ec..8bbab436d6c8 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts @@ -88,6 +88,7 @@ export const DEFAULT_SCHEDULER_OPTIONS: Properties = { mode: 'standard', }, allDayPanelMode: 'all', + snapToCellsMode: undefined, toolbar: { disabled: false, multiline: false, diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/types.ts b/packages/devextreme/js/__internal/scheduler/utils/options/types.ts index f74a3670a0d3..b3e17098bce7 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/types.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/types.ts @@ -77,6 +77,7 @@ type RequiredOptions = 'views' | 'adaptivityEnabled' | 'scrolling' | 'allDayPanelMode' + | 'snapToCellsMode' | 'toolbar'; export type DateOption = 'currentDate' | 'min' | 'max'; export type SafeSchedulerOptions = SchedulerInternalOptions diff --git a/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts b/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts index 729bace90338..98483bff3457 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts @@ -11,6 +11,7 @@ export const getSchedulerMock = ({ resourceManager, dateRange, skippedDays, + isVirtualScrolling = false, }: { type: string; startDayHour: number; @@ -19,6 +20,7 @@ export const getSchedulerMock = ({ resourceManager?: ResourceManager; skippedDays?: number[]; dateRange?: Date[]; + isVirtualScrolling?: boolean; }): Scheduler => ({ timeZoneCalculator: mockTimeZoneCalculator, currentView: { type, skippedDays: skippedDays ?? [] }, @@ -37,6 +39,7 @@ export const getSchedulerMock = ({ }[name]), option: (name: string) => ({ firstDayOfWeek: 0, showAllDayPanel: true }[name]), getViewOffsetMs: () => offsetMinutes * 60_000, + isVirtualScrolling: () => isVirtualScrolling, resourceManager: resourceManager ?? new ResourceManager([]), _dataAccessors: mockAppointmentDataAccessor, }) as unknown as Scheduler; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts index 8a64148ad289..2680e49cb311 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts @@ -23,6 +23,7 @@ export const sortAppointments = ( const { isMonthView, hasAllDayPanel, + snapToCellsMode, viewOffset, compareOptions: { endDayHour }, } = optionManager.options; @@ -40,9 +41,11 @@ export const sortAppointments = ( sortByStartDate(innerStep1); sortByGroupIndex(innerStep1); const innerStep2 = addPosition(innerStep1, optionManager.getCells(panelName)); - const innerStep3 = isMonthView || panelName === 'allDayPanel' - ? snapToCells(innerStep2, optionManager.getCells(panelName)) - : innerStep2; + const innerStep3 = snapToCells( + innerStep2, + optionManager.getCells(panelName), + panelName === 'allDayPanel' ? 'always' : snapToCellsMode, + ); const innerStep4 = addCollector(innerStep3, optionManager.getCollectorOptions(panelName)); return innerStep4; }); 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 c5656557dcd0..5154129aeee5 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 @@ -1,27 +1,48 @@ import type { Orientation } from '@js/common'; +import type { SnapToCellsMode } from '@js/ui/scheduler'; import type Scheduler from '@ts/scheduler/m_scheduler'; import type { ViewType } from '../../../types'; import { getCompareOptions } from '../../common/get_compare_options'; import type { CompareOptions } from '../../types'; -const configByView: Record, { +interface ViewConfig { isTimelineView: boolean; isMonthView: boolean; viewOrientation: 'horizontal' | 'vertical'; -}> = { - day: { isTimelineView: false, isMonthView: false, viewOrientation: 'vertical' }, - week: { isTimelineView: false, isMonthView: false, viewOrientation: 'vertical' }, - workWeek: { isTimelineView: false, isMonthView: false, viewOrientation: 'vertical' }, - month: { isTimelineView: false, isMonthView: true, viewOrientation: 'horizontal' }, - timelineDay: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, - timelineWeek: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, - timelineWorkWeek: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, - timelineMonth: { isTimelineView: true, isMonthView: true, viewOrientation: 'horizontal' }, + snapToCellsMode: SnapToCellsMode; +} + +const configByView: Record, ViewConfig> = { + day: { + isTimelineView: false, isMonthView: false, viewOrientation: 'vertical', snapToCellsMode: 'never', + }, + week: { + isTimelineView: false, isMonthView: false, viewOrientation: 'vertical', snapToCellsMode: 'never', + }, + workWeek: { + isTimelineView: false, isMonthView: false, viewOrientation: 'vertical', snapToCellsMode: 'never', + }, + month: { + isTimelineView: false, isMonthView: true, viewOrientation: 'horizontal', snapToCellsMode: 'always', + }, + timelineDay: { + isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal', snapToCellsMode: 'never', + }, + timelineWeek: { + isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal', snapToCellsMode: 'never', + }, + timelineWorkWeek: { + isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal', snapToCellsMode: 'never', + }, + timelineMonth: { + isTimelineView: true, isMonthView: true, viewOrientation: 'horizontal', snapToCellsMode: 'always', + }, }; export interface ViewModelOptions { type: ViewType; + snapToCellsMode: SnapToCellsMode; viewOffset: number; groupOrientation?: Orientation; isGroupByDate: boolean; @@ -47,16 +68,23 @@ export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions && schedulerStore.getViewOption('groupByDate'), ); const compareOptions = getCompareOptions(schedulerStore); - const { isTimelineView, isMonthView, viewOrientation } = configByView[type]; + const { + isTimelineView, + isMonthView, + viewOrientation, + snapToCellsMode: defaultSnapToCellsMode, + } = configByView[type]; const isRTLEnabled = Boolean(schedulerStore.option('rtlEnabled')); const isAdaptivityEnabled = Boolean(schedulerStore.option('adaptivityEnabled')); const cellDurationMinutes = schedulerStore.getViewOption('cellDuration'); const allDayPanelMode = schedulerStore.getViewOption('allDayPanelMode'); + const snapToCellsMode = schedulerStore.getViewOption('snapToCellsMode'); const showAllDayPanel = schedulerStore.getViewOption('showAllDayPanel'); const isVirtualScrolling = schedulerStore.isVirtualScrolling(); return { type, + snapToCellsMode: snapToCellsMode ?? defaultSnapToCellsMode, viewOffset, groupOrientation, isGroupByDate, diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.test.ts index d64e547ba71f..b8294dcbb5f1 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.test.ts @@ -2,94 +2,109 @@ import { describe, expect, it } from '@jest/globals'; import { snapToCells } from './snap_to_cells'; +const cells = [ + { + min: 0, max: 10, cellIndex: 0, rowIndex: 0, columnIndex: 0, + }, + { + min: 10, max: 20, cellIndex: 1, rowIndex: 0, columnIndex: 1, + }, + { + min: 20, max: 30, cellIndex: 2, rowIndex: 0, columnIndex: 2, + }, + { + min: 30, max: 40, cellIndex: 3, rowIndex: 1, columnIndex: 0, + }, + { + min: 40, max: 50, cellIndex: 4, rowIndex: 2, columnIndex: 1, + }, +]; + describe('snapToCells', () => { - it('should snap appointments to cells', () => { - const items = [{ - duration: 0, - cellIndex: 0, - endCellIndex: 0, - rowIndex: 0, - columnIndex: 0, - }, - { - duration: 0, - cellIndex: 3, - endCellIndex: 4, - rowIndex: 1, - columnIndex: 0, - }, - { - duration: 0, - cellIndex: 0, - endCellIndex: 2, - rowIndex: 0, - columnIndex: 0, - }, - { - duration: 0, - cellIndex: 4, - endCellIndex: 4, - rowIndex: 2, - columnIndex: 1, - }, - { - duration: 0, - cellIndex: 5, - endCellIndex: 5, - rowIndex: 3, - columnIndex: 2, - }]; + describe('always mode', () => { + it('should snap appointments to cell boundaries', () => { + const items = [ + { + cellIndex: 0, endCellIndex: 0, startDateUTC: 2, endDateUTC: 8, duration: 6, + }, + { + cellIndex: 3, endCellIndex: 4, startDateUTC: 32, endDateUTC: 48, duration: 16, + }, + { + cellIndex: 0, endCellIndex: 2, startDateUTC: 3, endDateUTC: 27, duration: 24, + }, + ]; + + expect(snapToCells(items as any, cells, 'always')).toEqual([ + expect.objectContaining({ + startDateUTC: 0, endDateUTC: 10, duration: 10, + }), + expect.objectContaining({ + startDateUTC: 30, endDateUTC: 50, duration: 20, + }), + expect.objectContaining({ + startDateUTC: 0, endDateUTC: 30, duration: 30, + }), + ]); + }); + }); + + describe('never mode', () => { + it('should return same reference without changes', () => { + const items = [ + { + cellIndex: 0, endCellIndex: 0, startDateUTC: 2, endDateUTC: 10, duration: 8, + }, + { + cellIndex: 1, endCellIndex: 2, startDateUTC: 12, endDateUTC: 27, duration: 15, + }, + ]; + + expect(snapToCells(items as any, cells, 'never')).toBe(items); + }); + }); + + describe('auto mode', () => { + it('should snap both boundaries when cells are covered by more than 50%', () => { + const items = [ + { + cellIndex: 0, endCellIndex: 1, startDateUTC: 2, endDateUTC: 16, duration: 14, + }, + ]; + + expect(snapToCells(items as any, cells, 'auto')).toEqual([ + expect.objectContaining({ + startDateUTC: 0, endDateUTC: 20, duration: 20, + }), + ]); + }); + + it('should not snap boundary when cell is covered by less than 50%', () => { + const items = [ + { + cellIndex: 0, endCellIndex: 0, startDateUTC: 2, endDateUTC: 7, duration: 5, + }, + ]; + + expect(snapToCells(items as any, cells, 'auto')).toEqual([ + expect.objectContaining({ + startDateUTC: 2, endDateUTC: 7, duration: 5, + }), + ]); + }); + + it('should not snap boundary when cell is covered by exactly 50%', () => { + const items = [ + { + cellIndex: 0, endCellIndex: 0, startDateUTC: 0, endDateUTC: 5, duration: 5, + }, + ]; - expect(snapToCells(items as any, [ - { - min: 0, max: 10, cellIndex: 0, rowIndex: 0, columnIndex: 0, - }, - { - min: 10, max: 20, cellIndex: 1, rowIndex: 0, columnIndex: 1, - }, - { - min: 20, max: 30, cellIndex: 2, rowIndex: 0, columnIndex: 2, - }, - { - min: 30, max: 40, cellIndex: 3, rowIndex: 1, columnIndex: 0, - }, - { - min: 40, max: 50, cellIndex: 4, rowIndex: 2, columnIndex: 1, - }, - { - min: 50, max: 60, cellIndex: 5, rowIndex: 3, columnIndex: 2, - }, - ])).toEqual([ - { - ...items[0], - duration: 10, - startDateUTC: 0, - endDateUTC: 10, - }, - { - ...items[1], - duration: 20, - startDateUTC: 30, - endDateUTC: 50, - }, - { - ...items[2], - duration: 30, - startDateUTC: 0, - endDateUTC: 30, - }, - { - ...items[3], - duration: 10, - startDateUTC: 40, - endDateUTC: 50, - }, - { - ...items[4], - duration: 10, - startDateUTC: 50, - endDateUTC: 60, - }, - ]); + expect(snapToCells(items as any, cells, 'auto')).toEqual([ + expect.objectContaining({ + startDateUTC: 0, endDateUTC: 5, duration: 5, + }), + ]); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.ts index 7d0d02b85e4b..f8de32dfd18e 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/steps/snap_to_cells.ts @@ -1,24 +1,48 @@ +import type { SnapToCellsMode } from '@js/ui/scheduler'; + import type { CellInterval, ListEntity, Position, } from '../../types'; +const getCellFill = ( + startDateUTC: number, + endDateUTC: number, + cell: CellInterval, +): number => { + const cellDuration = cell.max - cell.min; + if (cellDuration <= 0) return 0; + + const overlapStart = Math.max(startDateUTC, cell.min); + const overlapEnd = Math.min(endDateUTC, cell.max); + const overlapDuration = Math.max(0, overlapEnd - overlapStart); + + return overlapDuration / cellDuration; +}; + export const snapToCells = ( entities: T[], cells: CellInterval[], - isSnapToCell = true, + mode: SnapToCellsMode = 'always', ): T[] => { - if (!isSnapToCell) { - return entities; - } + if (mode === 'never') return entities; return entities.map((entity) => { - const { cellIndex, endCellIndex } = entity; + const startCell = cells[entity.cellIndex]; + const endCell = cells[entity.endCellIndex]; + + const snapStart = mode === 'always' + || getCellFill(entity.startDateUTC, entity.endDateUTC, startCell) > 0.5; + const snapEnd = mode === 'always' + || getCellFill(entity.startDateUTC, entity.endDateUTC, endCell) > 0.5; + + const startDateUTC = snapStart ? startCell.min : entity.startDateUTC; + const endDateUTC = snapEnd ? endCell.max : entity.endDateUTC; return { ...entity, - startDateUTC: cells[cellIndex].min, - endDateUTC: cells[endCellIndex].max, - duration: cells[endCellIndex].max - cells[cellIndex].min, + startDateUTC, + endDateUTC, + duration: endDateUTC - startDateUTC, }; }); };