diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 9d0f11dceef1..73054577d215 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -78,6 +78,7 @@ import { setAppointmentGroupValues } from './utils/resource_manager/appointment_ import { createResourceEditorModel } from './utils/resource_manager/popup_utils'; import { ResourceManager } from './utils/resource_manager/resource_manager'; import AppointmentLayoutManager from './view_model/appointments_layout_manager'; +import type { Occurrence } from './view_model/filtration/get_occurrences'; import { AppointmentDataSource } from './view_model/m_appointment_data_source'; import type { AppointmentViewModelPlain } from './view_model/types'; import SchedulerAgenda from './workspaces/m_agenda'; @@ -2148,6 +2149,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { } } + getOccurrences(startDate: Date, endDate: Date, rawAppointments: Appointment[]): Occurrence[] { + return this._layoutManager.getOccurrences(startDate, endDate, rawAppointments); + } + getFirstDayOfWeek(): FirstDayOfWeek { return isDefined(this.getViewOption('firstDayOfWeek')) ? this.getViewOption('firstDayOfWeek') as FirstDayOfWeek 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 new file mode 100644 index 000000000000..729bace90338 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts @@ -0,0 +1,42 @@ +import { mockAppointmentDataAccessor } from '../../__mock__/appointment_data_accessor.mock'; +import { mockTimeZoneCalculator } from '../../__mock__/timezone_calculator.mock'; +import type Scheduler from '../../m_scheduler'; +import { ResourceManager } from '../../utils/resource_manager/resource_manager'; + +export const getSchedulerMock = ({ + type, + startDayHour, + endDayHour, + offsetMinutes, + resourceManager, + dateRange, + skippedDays, +}: { + type: string; + startDayHour: number; + endDayHour: number; + offsetMinutes: number; + resourceManager?: ResourceManager; + skippedDays?: number[]; + dateRange?: Date[]; +}): Scheduler => ({ + timeZoneCalculator: mockTimeZoneCalculator, + currentView: { type, skippedDays: skippedDays ?? [] }, + getWorkSpace: () => ({ + getDateRange: () => dateRange ?? [ + new Date(2000, 0, 10, startDayHour), + new Date(2000, 0, 11, endDayHour), + ], + }), + getTimeZone: () => 'Etc/UTC', + getViewOption: (name: string) => ({ + startDayHour, + endDayHour, + allDayPanelMode: 'allDay', + cellDuration: 30, + }[name]), + option: (name: string) => ({ firstDayOfWeek: 0, showAllDayPanel: true }[name]), + getViewOffsetMs: () => offsetMinutes * 60_000, + resourceManager: resourceManager ?? new ResourceManager([]), + _dataAccessors: mockAppointmentDataAccessor, +}) as unknown as Scheduler; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.test.ts new file mode 100644 index 000000000000..e0a869423564 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.test.ts @@ -0,0 +1,453 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getSchedulerMock } from './__mock__/scheduler.mock'; +import AppointmentLayoutManager from './appointments_layout_manager'; + +const defaultOptions = { + type: 'week', + startDayHour: 9, + endDayHour: 18, + offsetMinutes: 0, +}; + +describe('getOccurrences', () => { + describe('common appointments', () => { + it('should return occurrence', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences with extra field', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + customField: 'custom value', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences if appointment intersects partially', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T18:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T12:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences if interval is in appointment', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T18:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T07:00:00Z'), + new Date('2000-01-01T12:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should not return occurrences out of interval - 1', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-03T06:00:00Z'), + endDate: new Date('2000-01-03T07:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([]); + }); + + it('should not return occurrences out of interval - 2', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-02T00:01:00Z'), + endDate: new Date('2000-01-02T01:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([]); + }); + + it('should not return occurrences out of interval - 3', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T23:00:00Z'), + endDate: new Date('2000-01-01T23:59:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-02T00:00:00Z'), + new Date('2000-01-03T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([]); + }); + + it('should return occurrences if scheduler has skipped days, and appointment intersects with them', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock({ + ...defaultOptions, + skippedDays: [0, 6], + })); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-03T00:00:00Z'), + [appointment], + ); + + expect(new Date('2000-01-01T06:00:00Z').getDay()).toBe(6); // weekend + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences out of scheduler day hours', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock({ + ...defaultOptions, + startDayHour: 8, + endDayHour: 17, + })); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T02:00:00Z'), + endDate: new Date('2000-01-01T03:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences out of scheduler visible dates', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock({ + ...defaultOptions, + dateRange: [ + new Date(2000, 0, 10, 9), + new Date(2000, 0, 16, 18), + ], + })); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrence if appointment has visible=false', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + visible: false, + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + }); + + describe('recurring appointments', () => { + it('should return occurrences', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + recurrenceRule: 'FREQ=DAILY;INTERVAL=4', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-07T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + appointmentData: { ...appointment }, + }, { + startDate: new Date('2000-01-05T06:00:00Z'), + endDate: new Date('2000-01-05T07:00:00Z'), + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences with extra fields', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + recurrenceRule: 'FREQ=DAILY;INTERVAL=4', + customField: 'custom value', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-07T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + appointmentData: { ...appointment }, + }, { + startDate: new Date('2000-01-05T06:00:00Z'), + endDate: new Date('2000-01-05T07:00:00Z'), + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences with exceptions', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + recurrenceRule: 'FREQ=DAILY;INTERVAL=2', + recurrenceException: '20000105T060000Z', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-06T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + appointmentData: { ...appointment }, + }, { + startDate: new Date('2000-01-03T06:00:00Z'), + endDate: new Date('2000-01-03T07:00:00Z'), + appointmentData: { ...appointment }, + }]); + }); + + it('should return occurrences out of recurring appointment with startDate out of interval', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + recurrenceRule: 'FREQ=DAILY;INTERVAL=2', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-05T00:00:00Z'), + new Date('2000-01-08T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: new Date('2000-01-05T06:00:00Z'), + endDate: new Date('2000-01-05T07:00:00Z'), + appointmentData: { ...appointment }, + }, { + startDate: new Date('2000-01-07T06:00:00Z'), + endDate: new Date('2000-01-07T07:00:00Z'), + appointmentData: { ...appointment }, + }]); + }); + }); + + describe('all day appointments', () => { + it('should return occurrences if all day appointment\' date intersects with the given interval', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T01:00:00Z'), + endDate: new Date('2000-01-01T02:00:00Z'), + allDay: true, + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T12:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([{ + startDate: appointment.startDate, + endDate: appointment.endDate, + appointmentData: { ...appointment }, + }]); + }); + + it('should not return occurrences if appointment is not in interval - 1', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-01-01T01:00:00Z'), + endDate: new Date('2000-01-01T02:00:00Z'), + allDay: true, + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-02T00:00:00Z'), + new Date('2000-01-03T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([]); + }); + + it('should not return occurrences if appointment is not in interval - 2', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test', + startDate: new Date('2000-03-01T01:00:00Z'), + endDate: new Date('2000-03-01T02:00:00Z'), + allDay: true, + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-02T00:00:00Z'), + [appointment], + ); + + expect(occurrences).toEqual([]); + }); + }); + + it('should return occurrences for common and recurring appointments together', () => { + const layoutManager = new AppointmentLayoutManager(getSchedulerMock(defaultOptions)); + + const appointment = { + text: 'test 1', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + }; + const recurringAppointment = { + text: 'test 2', + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + recurrenceRule: 'FREQ=DAILY;INTERVAL=3', + }; + const occurrences = layoutManager.getOccurrences( + new Date('2000-01-01T00:00:00Z'), + new Date('2000-01-06T00:00:00Z'), + [appointment, recurringAppointment], + ); + + expect(occurrences).toEqual([{ + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + appointmentData: { ...appointment }, + }, { + startDate: new Date('2000-01-01T06:00:00Z'), + endDate: new Date('2000-01-01T07:00:00Z'), + appointmentData: { ...recurringAppointment }, + }, { + startDate: new Date('2000-01-04T06:00:00Z'), + endDate: new Date('2000-01-04T07:00:00Z'), + appointmentData: { ...recurringAppointment }, + }]); + }); +}); 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 60b063072665..6ea190eaa0dc 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 @@ -2,6 +2,8 @@ import type { Appointment } from '@js/ui/scheduler'; import type Scheduler from '../m_scheduler'; import { filterAppointments } from './filtration/filter_appointments'; +import type { Occurrence } from './filtration/get_occurrences'; +import { getOccurrences } from './filtration/get_occurrences'; import { generateAgendaViewModel } from './generate_view_model/generate_agenda_view_model'; import { generateGridViewModel } from './generate_view_model/generate_grid_view_model'; import type { RealSize } from './generate_view_model/steps/add_geometry/types'; @@ -33,6 +35,22 @@ class AppointmentLayoutManager { this.filteredItems = filterAppointments(this.schedulerStore, this.preparedItems); } + public getOccurrences( + startDate: Date, + endDate: Date, + rawAppointments: Appointment[], + ): Occurrence[] { + const preparedAppointments = prepareAppointments(this.schedulerStore, rawAppointments); + const occurrences = getOccurrences( + this.schedulerStore, + startDate, + endDate, + preparedAppointments, + ); + + return occurrences; + } + public hasAllDayAppointments(): boolean { return this.filteredItems.filter((item: ListEntity) => item.isAllDayPanelOccupied).length > 0; } diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/get_occurrences.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/get_occurrences.ts new file mode 100644 index 000000000000..551071fc13c6 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/get_occurrences.ts @@ -0,0 +1,51 @@ +import type Scheduler from '../../m_scheduler'; +import type { + CompareOptions, FilterOptions, MinimalAppointmentEntity, +} from '../types'; +import { addAllDayPanelOccupation } from './utils/add_all_day_panel_occupation'; +import { filterByIntervals } from './utils/filter_by_intervals/filter_by_intervals'; +import { getFilterOptions } from './utils/get_filter_options/get_filter_options'; +import { splitByRecurrence } from './utils/split_by_recurrence/split_by_recurrence'; + +export interface Occurrence { + startDate: Date; + endDate: Date; + appointmentData: object; +} + +export const getOccurrences = ( + schedulerStore: Scheduler, + startDate: Date, + endDate: Date, + appointments: MinimalAppointmentEntity[], +): Occurrence[] => { + const compareOptions: CompareOptions = { + // NOTE: days hours are intentionally set to 0 and 24 + // to return all appointments that intersect with [startDate; endDate] + startDayHour: 0, + endDayHour: 24, + min: startDate.getTime(), + max: endDate.getTime(), + skippedDays: [], + }; + + const filterOptions: FilterOptions = { + ...getFilterOptions(schedulerStore, compareOptions), + // NOTE: to return allDay appointments if they intersect with [startDate; endDate] + allDayPanelMode: 'allDay', + supportAllDayPanel: true, + isDateTimeView: true, + }; + + const step1 = addAllDayPanelOccupation(appointments, filterOptions); + const step2 = splitByRecurrence(step1, filterOptions); + const step3 = filterByIntervals(step2, filterOptions); + + const step4: Occurrence[] = step3.map((appointment) => ({ + startDate: new Date(appointment.source.startDate), + endDate: new Date(appointment.source.endDate), + appointmentData: appointment.itemData, + })); + + return step4; +}; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.test.ts index b3f99c056691..43a7d4160d7b 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.test.ts @@ -1,41 +1,9 @@ import { describe, expect, it } from '@jest/globals'; +import { getSchedulerMock } from '@ts/scheduler/view_model/__mock__/scheduler.mock'; import { getCompareOptions } from '@ts/scheduler/view_model/common/get_compare_options'; -import { mockAppointmentDataAccessor } from '../../../../__mock__/appointment_data_accessor.mock'; -import type Scheduler from '../../../../m_scheduler'; -import { ResourceManager } from '../../../../utils/resource_manager/resource_manager'; import { getFilterOptions } from './get_filter_options'; -export const getSchedulerMock = ({ - type, - startDayHour, - endDayHour, - offsetMinutes, - resourceManager, - dateRange, -}: { - type: string; - startDayHour: number; - endDayHour: number; - offsetMinutes: number; - resourceManager?: ResourceManager; - dateRange?: Date[]; -}): Scheduler => ({ - currentView: { type, skippedDays: [] }, - getWorkSpace: () => ({ - getDateRange: () => dateRange ?? [ - new Date(2000, 0, 10, startDayHour), - new Date(2000, 0, 11, endDayHour), - ], - }), - getTimeZone: () => 'Etc/UTC', - getViewOption: (name: string) => ({ startDayHour, endDayHour, allDayPanelMode: 'allDay' }[name]), - option: (name: string) => ({ firstDayOfWeek: 0, showAllDayPanel: true }[name]), - getViewOffsetMs: () => offsetMinutes * 60_000, - resourceManager: resourceManager ?? new ResourceManager([]), - _dataAccessors: mockAppointmentDataAccessor, -}) as unknown as Scheduler; - describe('getFilterOptions', () => { ['agenda', 'month'].forEach((type) => { it(`should return correct filter options for ${type} view`, () => { @@ -44,6 +12,10 @@ describe('getFilterOptions', () => { startDayHour: 0, endDayHour: 24, offsetMinutes: 30, + dateRange: [ + new Date(2000, 0, 10, 0), + new Date(2000, 0, 11, 24), + ], }); const compareOptions = getCompareOptions(schedulerStore);