diff --git a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss index 1e86184428c2..506ea19a44c5 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss @@ -870,6 +870,8 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; } .dx-scheduler-appointment:not(.dx-scheduler-appointment-has-resource-color) { + container-type: size; + .dx-scheduler-appointment-strip { display: block; position: absolute; @@ -877,6 +879,12 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; height: 100%; background-color: $base-accent; } + + @container (max-width: 8px) { + .dx-scheduler-appointment-strip { + display: none; + } + } } .dx-rtl.dx-scheduler-appointment:not(.dx-scheduler-appointment-has-resource-color) { diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 25f85f1d6836..a8ca60b9e7b6 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -9,6 +9,7 @@ export type Direction = 'vertical' | 'horizontal'; export type GroupOrientation = 'vertical' | 'horizontal'; export type ViewType = 'agenda' | 'day' | 'month' | 'timelineDay' | 'timelineMonth' | 'timelineWeek' | 'timelineWorkWeek' | 'week' | 'workWeek'; export type AllDayPanelModeType = 'all' | 'allDay' | 'hidden'; +export type SnapToCellsModeType = 'always' | 'auto' | 'never'; export type RenderStrategyName = 'horizontal' | 'horizontalMonth' | 'horizontalMonthLine' | 'vertical' | 'week' | 'agenda'; export type FilterItemType = Record | string | number; export type HeaderCellTextFormat = string | ((date: Date) => string); 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/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.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.test.ts new file mode 100644 index 000000000000..7f7e2f75468c --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getDefaultSnapToCellsModeForView } from './get_view_model_options'; + +describe('getDefaultSnapToCellsModeForView', () => { + it.each([ + { viewType: 'month' as const, expected: 'always' }, + { viewType: 'agenda' as const, expected: 'always' }, + { viewType: 'timelineMonth' as const, expected: 'always' }, + { viewType: 'day' as const, expected: 'never' }, + { viewType: 'week' as const, expected: 'never' }, + { viewType: 'workWeek' as const, expected: 'never' }, + { viewType: 'timelineDay' as const, expected: 'never' }, + { viewType: 'timelineWeek' as const, expected: 'never' }, + { viewType: 'timelineWorkWeek' as const, expected: 'never' }, + ])('should return $expected for $viewType', ({ viewType, expected }) => { + expect(getDefaultSnapToCellsModeForView(viewType)).toBe(expected); + }); +}); 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..0916c94dd9a4 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,7 +1,7 @@ import type { Orientation } from '@js/common'; import type Scheduler from '@ts/scheduler/m_scheduler'; -import type { ViewType } from '../../../types'; +import type { SnapToCellsModeType, ViewType } from '../../../types'; import { getCompareOptions } from '../../common/get_compare_options'; import type { CompareOptions } from '../../types'; @@ -22,6 +22,7 @@ const configByView: Record, { export interface ViewModelOptions { type: ViewType; + snapToCellsMode: SnapToCellsModeType; viewOffset: number; groupOrientation?: Orientation; isGroupByDate: boolean; @@ -37,6 +38,10 @@ export interface ViewModelOptions { isVirtualScrolling: boolean; } +export const getDefaultSnapToCellsModeForView = (type: ViewType): SnapToCellsModeType => ( + ['month', 'agenda', 'timelineMonth'].includes(type) ? 'always' : 'never' +); + export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions => { const viewOffset = schedulerStore.getViewOffsetMs(); const { groupOrientation, type } = schedulerStore.currentView; @@ -52,11 +57,13 @@ export const getViewModelOptions = (schedulerStore: Scheduler): ViewModelOptions 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 ?? getDefaultSnapToCellsModeForView(type), 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..48484422aabe 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,52 @@ +import type { SnapToCellsModeType } from '../../../types'; 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: SnapToCellsModeType = 'always', ): T[] => { - if (!isSnapToCell) { - return entities; + if (mode === 'never') return entities; + + if (mode === 'always') { + return entities.map((entity) => { + const startDateUTC = cells[entity.cellIndex].min; + const endDateUTC = cells[entity.endCellIndex].max; + + return { + ...entity, startDateUTC, endDateUTC, duration: endDateUTC - startDateUTC, + }; + }); } return entities.map((entity) => { - const { cellIndex, endCellIndex } = entity; + const startCell = cells[entity.cellIndex]; + const endCell = cells[entity.endCellIndex]; + + const startDateUTC = getCellFill(entity.startDateUTC, entity.endDateUTC, startCell) > 0.5 + ? startCell.min : entity.startDateUTC; + const endDateUTC = getCellFill(entity.startDateUTC, entity.endDateUTC, endCell) > 0.5 + ? endCell.max : entity.endDateUTC; return { - ...entity, - startDateUTC: cells[cellIndex].min, - endDateUTC: cells[endCellIndex].max, - duration: cells[endCellIndex].max - cells[cellIndex].min, + ...entity, startDateUTC, endDateUTC, duration: endDateUTC - startDateUTC, }; }); };