diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-horizontal-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-horizontal-rtl (fluent.blue.light).png index 2951d4d89383..bf96f9c74b09 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-horizontal-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-horizontal-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-rtl (fluent.blue.light).png index dca1873c3d78..24c3b7413f90 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png index d1d08a3b2462..e1ecc9630f1b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-horizontal-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-horizontal-rtl (fluent.blue.light).png index d4bd39f56bba..e1b2a962a1e8 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-horizontal-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-horizontal-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-rtl (fluent.blue.light).png index 6d8ef1223996..a88b23695a12 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 412497c1063a..53eec7dc5e16 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss index 1e86184428c2..ca168e9e7293 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss @@ -209,25 +209,25 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; &.dx-scheduler-appointment-recurrence { @container (max-height: #{$fluent-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $fluent-scheduler-appointment-10min-recurrence-padding-right; } } @container (min-height: #{$fluent-scheduler-appointment-15min-height}) and (max-height: #{$fluent-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $fluent-scheduler-appointment-15min-recurrence-padding-right; } } @container (min-height: #{$fluent-scheduler-appointment-20min-height}) and (max-height: #{$fluent-scheduler-appointment-25min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $fluent-scheduler-appointment-20min-recurrence-padding-right; } } } - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { @container (max-height: #{$fluent-scheduler-appointment-25min-height}) { display: flex; align-items: center; @@ -245,7 +245,7 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; } @container (max-height: #{$fluent-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $fluent-scheduler-appointment-10min-title-font-size; line-height: $fluent-scheduler-appointment-10min-title-line-height; @@ -259,7 +259,7 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; } @container (min-height: #{$fluent-scheduler-appointment-15min-height}) and (max-height: #{$fluent-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $fluent-scheduler-appointment-15min-title-font-size; line-height: $fluent-scheduler-appointment-15min-title-line-height; @@ -273,7 +273,7 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; } @container (min-height: #{$fluent-scheduler-appointment-20min-height}) and (max-height: #{$fluent-scheduler-appointment-25min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-recurrence-icon { right: $fluent-scheduler-appointment-20min-icon-right; } diff --git a/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss index 16f816c52700..fc82711b346a 100644 --- a/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss @@ -587,25 +587,25 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c &.dx-scheduler-appointment-recurrence { @container (max-height: #{$generic-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $generic-scheduler-appointment-10min-recurrence-padding-right; } } @container (min-height: #{$generic-scheduler-appointment-15min-height}) and (max-height: #{$generic-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $generic-scheduler-appointment-15min-recurrence-padding-right; } } @container (min-height: #{$generic-scheduler-appointment-20min-height}) and (max-height: #{$generic-scheduler-appointment-25min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $generic-scheduler-appointment-20min-recurrence-padding-right; } } } - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { @container (max-height: #{$generic-scheduler-appointment-25min-height}) { display: flex; align-items: center; @@ -623,7 +623,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c } @container (max-height: #{$generic-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $generic-scheduler-appointment-10min-title-font-size; line-height: $generic-scheduler-appointment-10min-title-line-height; @@ -637,7 +637,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c } @container (min-height: #{$generic-scheduler-appointment-15min-height}) and (max-height: #{$generic-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $generic-scheduler-appointment-15min-title-font-size; line-height: $generic-scheduler-appointment-15min-title-line-height; @@ -652,7 +652,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c @if $size == "compact" { @container (max-height: #{$generic-scheduler-appointment-10min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { display: none; } } diff --git a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss index c7974b5d62bc..8a65dd85500b 100644 --- a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss @@ -182,25 +182,25 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; &.dx-scheduler-appointment-recurrence { @container (max-height: #{$material-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $material-scheduler-appointment-10min-recurrence-padding-right; } } @container (min-height: #{$material-scheduler-appointment-15min-height}) and (max-height: #{$material-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $material-scheduler-appointment-15min-recurrence-padding-right; } } @container (min-height: #{$material-scheduler-appointment-20min-height}) and (max-height: #{$material-scheduler-appointment-25min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { padding-right: $material-scheduler-appointment-20min-recurrence-padding-right; } } } - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { @container (max-height: #{$material-scheduler-appointment-25min-height}) { display: flex; align-items: center; @@ -218,7 +218,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; } @container (max-height: #{$material-scheduler-appointment-15min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $material-scheduler-appointment-10min-title-font-size; line-height: $material-scheduler-appointment-10min-title-line-height; @@ -232,7 +232,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; } @container (min-height: #{$material-scheduler-appointment-15min-height}) and (max-height: #{$material-scheduler-appointment-20min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-title { font-size: $material-scheduler-appointment-15min-title-font-size; line-height: $material-scheduler-appointment-15min-title-line-height; @@ -246,7 +246,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; } @container (min-height: #{$material-scheduler-appointment-20min-height}) and (max-height: #{$material-scheduler-appointment-25min-height}) { - .dx-item-content.dx-scheduler-appointment-content { + .dx-scheduler-appointment-content { .dx-scheduler-appointment-recurrence-icon { right: $material-scheduler-appointment-20min-icon-right; } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts new file mode 100644 index 000000000000..b6b9b422e044 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts @@ -0,0 +1,28 @@ +import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; +import { mockAppointmentDataAccessor } from '@ts/scheduler/__mock__/appointment_data_accessor.mock'; +import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; +import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; + +import type { BaseAppointmentViewProperties } from '../appointment/base_appointment'; + +export const getBaseAppointmentProperties = ( + appointmentData: SafeAppointment, + targetedAppointmentData?: TargetedAppointment, +): BaseAppointmentViewProperties => { + const normalizedTargetedAppointmentData = targetedAppointmentData ?? { + ...appointmentData, + displayStartDate: appointmentData.startDate as Date, + displayEndDate: appointmentData.endDate as Date, + }; + + const config: BaseAppointmentViewProperties = { + appointmentData, + targetedAppointmentData: normalizedTargetedAppointmentData, + appointmentTemplate: new EmptyTemplate(), + onAppointmentRendered: () => {}, + getDataAccessor: (): AppointmentDataAccessor => mockAppointmentDataAccessor, + getResourceColor: (): Promise => Promise.resolve(undefined), + }; + + return config; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_view_model.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_view_model.ts new file mode 100644 index 000000000000..e0ff8b959ff2 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_view_model.ts @@ -0,0 +1,92 @@ +import type { SafeAppointment } from '@ts/scheduler/types'; +import type { AppointmentAgendaViewModel, AppointmentCollectorViewModel, AppointmentItemViewModel } from '@ts/scheduler/view_model/types'; + +export const mockGridViewModel = ( + appointmentData: SafeAppointment, + partialViewModel?: Partial, +): AppointmentItemViewModel => { + const sourceAppointment = { + allDay: appointmentData.allDay, + startDate: appointmentData.startDate as Date, + endDate: appointmentData.endDate as Date, + }; + + const viewModel: AppointmentItemViewModel = { + itemData: appointmentData, + allDay: appointmentData.allDay ?? false, + groupIndex: appointmentData.groupIndex ?? 0, + sortedIndex: appointmentData.sortedIndex ?? 0, + info: { + sourceAppointment, + appointment: { ...sourceAppointment }, + }, + direction: 'horizontal', + skipResizing: false, + level: 0, + maxLevel: 0, + empty: false, + left: 0, + top: 0, + height: 0, + width: 0, + reduced: undefined, + partIndex: 0, + partTotalCount: 0, + rowIndex: 0, + columnIndex: 0, + }; + + return { + ...viewModel, + ...partialViewModel, + }; +}; + +export const mockAgendaViewModel = ( + appointmentData: SafeAppointment, + partialViewModel?: Partial, +): AppointmentAgendaViewModel => { + const sourceAppointment = { + allDay: appointmentData.allDay, + startDate: appointmentData.startDate as Date, + endDate: appointmentData.endDate as Date, + }; + + const viewModel: AppointmentAgendaViewModel = { + itemData: appointmentData, + allDay: appointmentData.allDay ?? false, + groupIndex: appointmentData.groupIndex ?? 0, + sortedIndex: appointmentData.sortedIndex ?? 0, + isAgendaModel: true, + height: 50, + width: '100', + isLastInGroup: appointmentData.isLastInGroup ?? false, + info: { + sourceAppointment, + appointment: { ...sourceAppointment }, + partialDates: { ...sourceAppointment }, + }, + }; + + return { + ...viewModel, + ...partialViewModel, + }; +}; + +export const mockAppointmentCollectorViewModel = ( + appointmentData: SafeAppointment, + partialViewModel?: Partial, +): AppointmentCollectorViewModel => ({ + itemData: appointmentData, + allDay: appointmentData.allDay ?? false, + groupIndex: appointmentData.groupIndex ?? 0, + sortedIndex: appointmentData.sortedIndex ?? 0, + top: 0, + left: 0, + height: 0, + width: 0, + isCompact: false, + items: [mockGridViewModel(appointmentData)], + ...partialViewModel, +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts new file mode 100644 index 000000000000..da1a3b867064 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts @@ -0,0 +1,219 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; +import type { AppointmentResource } from '@ts/scheduler/utils/resource_manager/appointment_groups_utils'; + +import fx from '../../../common/core/animation/fx'; +import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { AGENDA_APPOINTMENT_CLASSES, APPOINTMENT_CLASSES } from '../const'; +import type { AgendaAppointmentViewProperties } from './agenda_appointment'; +import { AgendaAppointmentView } from './agenda_appointment'; + +const getProperties = ( + appointmentData: SafeAppointment, + targetedAppointmentData?: TargetedAppointment, +): AgendaAppointmentViewProperties => { + const baseProperties = getBaseAppointmentProperties( + appointmentData, + targetedAppointmentData, + ); + + return { + ...baseProperties, + modifiers: { + isLastInGroup: false, + }, + geometry: { + height: 50, + width: '100%', + }, + getResourcesValues: (): Promise => Promise.resolve([]), + }; +}; + +const createAgendaAppointment = async ( + properties: AgendaAppointmentViewProperties, +): Promise => { + const $element = $('.root'); + + // @ts-expect-error + const instance = new AgendaAppointmentView($element, properties); + + // Await for resources + await new Promise(process.nextTick); + + return instance; +}; + +const defaultAppointmentData = { + text: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe('AgendaAppointment', () => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + fx.off = false; + jest.useRealTimers(); + }); + + describe('Classes', () => { + it.each([ + true, false, + ])('should have correct class for modifiers.lastInGroup = %o', async (isLastInGroup) => { + const instance = await createAgendaAppointment({ + ...getProperties(defaultAppointmentData), + modifiers: { isLastInGroup }, + }); + + expect( + instance.$element().hasClass(AGENDA_APPOINTMENT_CLASSES.LAST_IN_DATE), + ).toBe(isLastInGroup); + }); + }); + + describe('Title', () => { + it.each([ + { text: 'Test title', expected: 'Test title' }, + { text: undefined, expected: '(No subject)' }, + { text: '', expected: '(No subject)' }, + ])('should have correct title text for appointment text = %o', async ({ text, expected }) => { + const instance = await createAgendaAppointment( + getProperties({ + ...defaultAppointmentData, text, + }), + ); + + const $title = instance.$element().find(`.${APPOINTMENT_CLASSES.TITLE}`); + expect($title.text()).toBe(expected); + }); + }); + + describe('Date text', () => { + it('should have correct date text', async () => { + const instance = await createAgendaAppointment( + getProperties({ + ...defaultAppointmentData, + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), + }), + ); + + const $date = instance.$element().find(`.${APPOINTMENT_CLASSES.DATE}`); + + expect($date.text()).toBe('9:00 AM - 10:00 AM'); + }); + }); + + describe('Recurrence', () => { + it.each([ + true, false, + ])('should have correct recurrence icon visibility for isRecurring = %o', async (isRecurring) => { + const appointmentData = isRecurring + ? { ...defaultAppointmentData, recurrenceRule: 'FREQ=DAILY' } + : defaultAppointmentData; + + const instance = await createAgendaAppointment( + getProperties(appointmentData), + ); + + const $icon = instance.$element().find(`.${APPOINTMENT_CLASSES.RECURRENCE_ICON}`); + + expect($icon.length).toBe(isRecurring ? 1 : 0); + }); + }); + + describe('All Day', () => { + it.each([ + true, false, + ])('should have correct all day text visibility for allDay = %o', async (isAllDay) => { + const appointmentData = { ...defaultAppointmentData, allDay: isAllDay }; + + const instance = await createAgendaAppointment( + getProperties(appointmentData), + ); + + const $allDayText = instance.$element().find(`.${APPOINTMENT_CLASSES.ALL_DAY_TEXT}`); + + expect($allDayText.length).toBe(isAllDay ? 1 : 0); + }); + }); + + describe('Resources', () => { + it('should apply resource color', async () => { + const resourceColor = 'rgb(255, 0, 0)'; + + const instance = await createAgendaAppointment({ + ...getProperties(defaultAppointmentData), + getResourceColor: () => Promise.resolve(resourceColor), + }); + + const $marker = instance.$element().find(`.${AGENDA_APPOINTMENT_CLASSES.MARKER}`); + + expect($marker.css('backgroundColor')).toBe(resourceColor); + }); + + it('should render resource list', async () => { + const instance = await createAgendaAppointment({ + ...getProperties(defaultAppointmentData), + getResourcesValues: () => { + const resourceValues: AppointmentResource[] = [ + { label: 'roomId', values: ['Room 1'] }, + { label: 'ownerId', values: ['Owner 1'] }, + ]; + + return Promise.resolve(resourceValues); + }, + }); + + const $resourceItems = instance.$element().find(`.${AGENDA_APPOINTMENT_CLASSES.RESOURCE_ITEM}`); + + expect($resourceItems.length).toBe(2); + expect($resourceItems.eq(0).text()).toBe('roomId:Room 1'); + expect($resourceItems.eq(1).text()).toBe('ownerId:Owner 1'); + }); + + it('should not render resource list if there are no resources', async () => { + const instance = await createAgendaAppointment({ + ...getProperties(defaultAppointmentData), + getResourcesValues: () => Promise.resolve([]), + }); + + const $resourceItems = instance.$element().find(`.${AGENDA_APPOINTMENT_CLASSES.RESOURCE_ITEM}`); + + expect($resourceItems.length).toBe(0); + }); + }); + + describe('Geometry', () => { + it('should have correct height and width', async () => { + const instance = await createAgendaAppointment({ + ...getProperties({ + ...defaultAppointmentData, + }), + geometry: { + height: 60, + width: '80%', + }, + }); + + expect(instance.$element().css('height')).toBe('60px'); + expect(instance.$element().css('width')).toBe('80%'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts new file mode 100644 index 000000000000..0b08a316c87f --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts @@ -0,0 +1,127 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { SafeAppointment } from '@ts/scheduler/types'; +import type { AppointmentResource } from '@ts/scheduler/utils/resource_manager/appointment_groups_utils'; + +import { + AGENDA_APPOINTMENT_CLASSES, ALL_DAY_TEXT, APPOINTMENT_CLASSES, RECURRING_LABEL, +} from '../const'; +import type { BaseAppointmentViewProperties } from './base_appointment'; +import { BaseAppointmentView } from './base_appointment'; + +export interface AgendaAppointmentViewProperties extends BaseAppointmentViewProperties { + modifiers: { + isLastInGroup: boolean; + }; + + geometry: { + height: number; + width: string; + }; + + getResourcesValues: ( + appointmentData: SafeAppointment, + ) => Promise; +} + +export class AgendaAppointmentView extends BaseAppointmentView { + protected override applyElementClasses(): void { + super.applyElementClasses(); + + this.$element() + .toggleClass(AGENDA_APPOINTMENT_CLASSES.LAST_IN_DATE, this.option().modifiers.isLastInGroup); + } + + public override resize(): void { + this.$element().css({ + height: this.option().geometry.height, + width: this.option().geometry.width, + }); + } + + protected override defaultAppointmentContent( + $container: dxElementWrapper, + ): dxElementWrapper { + this.renderMarker($container); + this.renderInfo($container); + + return $container; + } + + private renderMarker($container: dxElementWrapper): void { + const $leftContainer = $('
') + .addClass('dx-scheduler-agenda-appointment-left-layout') + .appendTo($container); + + const $marker = $('
') + .addClass(AGENDA_APPOINTMENT_CLASSES.MARKER) + .appendTo($leftContainer); + + // eslint-disable-next-line no-void + void this.option().getResourceColor().then((color) => { + if (color) { + $marker.css('backgroundColor', color); + } + }); + + if (this.isRecurring()) { + $('') + .addClass(`${APPOINTMENT_CLASSES.RECURRENCE_ICON} dx-icon-repeat`) + .attr('aria-label', RECURRING_LABEL) + .appendTo($marker); + } + } + + private renderInfo($container: dxElementWrapper): void { + const $rightContainer = $('
') + .addClass('dx-scheduler-agenda-appointment-right-layout') + .appendTo($container); + + $('
') + .addClass(APPOINTMENT_CLASSES.TITLE) + .text(this.getTitleText()) + .appendTo($rightContainer); + + const $contentDetails = $('
') + .addClass(APPOINTMENT_CLASSES.DETAILS) + .appendTo($rightContainer); + + $('
') + .addClass(APPOINTMENT_CLASSES.DATE) + .text(this.getDateText()) + .appendTo($contentDetails); + + if (this.isAllDay()) { + $('
') + .text(ALL_DAY_TEXT) + .addClass(APPOINTMENT_CLASSES.ALL_DAY_TEXT) + .prependTo($contentDetails); + } + + this.renderResourceList($contentDetails); + } + + private renderResourceList($contentDetails: dxElementWrapper): void { + // eslint-disable-next-line no-void + void this.option().getResourcesValues(this.appointmentData).then((resources) => { + const container = $('
') + .addClass(AGENDA_APPOINTMENT_CLASSES.RESOURCE_LIST) + .appendTo($contentDetails); + + resources.forEach((resource) => { + const itemContainer = $('
') + .addClass(AGENDA_APPOINTMENT_CLASSES.RESOURCE_ITEM) + .appendTo(container); + + $('
') + .text(`${resource.label}:`) + .appendTo(itemContainer); + + $('
') + .addClass(AGENDA_APPOINTMENT_CLASSES.RESOURCE_ITEM_VALUE) + .text(resource.values.join(', ')) + .appendTo(itemContainer); + }); + }); + } +} diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts new file mode 100644 index 000000000000..de9ea8198a70 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts @@ -0,0 +1,100 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; + +import fx from '../../../common/core/animation/fx'; +import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; +import type { BaseAppointmentViewProperties } from './base_appointment'; +import { BaseAppointmentView } from './base_appointment'; + +const createBaseAppointment = async ( + properties: BaseAppointmentViewProperties, +): Promise => { + const $element = $('.root'); + + // @ts-expect-error + const instance = new BaseAppointmentView($element, properties); + + // Await for resources + await new Promise(process.nextTick); + + return instance; +}; + +const defaultAppointmentData = { + title: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe('BaseAppointment', () => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + fx.off = false; + jest.useRealTimers(); + }); + + describe('Classes', () => { + it('should have container class', async () => { + const instance = await createBaseAppointment( + getBaseAppointmentProperties(defaultAppointmentData), + ); + + expect(instance.$element().hasClass(APPOINTMENT_CLASSES.CONTAINER)).toBe(true); + }); + + it.each([ + true, false, + ])('should have correct class for isRecurring = %o', async (isRecurring) => { + const instance = await createBaseAppointment( + getBaseAppointmentProperties({ + ...defaultAppointmentData, + recurrenceRule: isRecurring ? 'FREQ=DAILY;COUNT=5' : undefined, + }), + ); + + expect( + instance.$element().hasClass(APPOINTMENT_TYPE_CLASSES.RECURRING), + ).toBe(isRecurring); + }); + + it.each([ + true, false, + ])('should have correct class for allDay = %o', async (allDay) => { + const instance = await createBaseAppointment( + getBaseAppointmentProperties({ + ...defaultAppointmentData, + allDay, + }), + ); + + expect( + instance.$element().hasClass(APPOINTMENT_TYPE_CLASSES.ALL_DAY), + ).toBe(allDay); + }); + }); + + describe('Aria', () => { + it('should have role button', async () => { + const instance = await createBaseAppointment( + getBaseAppointmentProperties(defaultAppointmentData), + ); + + expect(instance.$element().attr('role')).toBe('button'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts new file mode 100644 index 000000000000..3334c3143058 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts @@ -0,0 +1,147 @@ +import messageLocalization from '@js/common/core/localization/message'; +import registerComponent from '@js/core/component_registrator'; +import type { DxElement } from '@js/core/element'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { when } from '@js/core/utils/deferred'; +import { getPublicElement } from '@ts/core/m_element'; +import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; +import { FunctionTemplate } from '@ts/core/templates/m_function_template'; +import type { TemplateBase } from '@ts/core/templates/m_template_base'; +import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; +import DOMComponent from '@ts/core/widget/dom_component'; +import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; +import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; + +import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; +import { DateFormatType, getDateTextFromTargetAppointment } from '../utils/get_date_text'; + +export interface BaseAppointmentViewProperties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extends DOMComponentProperties> { + appointmentData: SafeAppointment; + targetedAppointmentData: TargetedAppointment; + appointmentTemplate: TemplateBase; + + onAppointmentRendered: (e: { + element: DxElement; + appointmentData: SafeAppointment; + targetedAppointmentData: TargetedAppointment; + }) => void; + + getDataAccessor: () => AppointmentDataAccessor; + getResourceColor: () => Promise; +} + +export class BaseAppointmentView< + TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties, +> extends DOMComponent, TProperties> { + protected get targetedAppointmentData(): TargetedAppointment { + return this.option().targetedAppointmentData; + } + + protected get appointmentData(): SafeAppointment { + return this.option().appointmentData; + } + + private defaultAppointmentTemplate!: FunctionTemplate; + + override _init(): void { + super._init(); + + this.defaultAppointmentTemplate = new FunctionTemplate((options) => { + this.defaultAppointmentContent($(options.container)); + }); + } + + override _initMarkup(): void { + super._initMarkup(); + + this.resize(); + this.applyElementClasses(); + this.applyAria(); + this.renderContentTemplate(); + } + + public resize(): void { } + + protected applyElementClasses(): void { + this.$element() + .addClass(APPOINTMENT_CLASSES.CONTAINER) + .toggleClass(APPOINTMENT_TYPE_CLASSES.RECURRING, this.isRecurring()) + .toggleClass(APPOINTMENT_TYPE_CLASSES.ALL_DAY, this.isAllDay()); + } + + protected applyAria(): void { + this.$element() + .attr('role', 'button'); + } + + protected getTitleText(): string { + const dataAccessor = this.option().getDataAccessor(); + const titleText = dataAccessor.get('text', this.targetedAppointmentData); + + if (!titleText) { + return messageLocalization.format('dxScheduler-noSubject'); + } + + return titleText; + } + + protected getDateText(): string { + const dateText = getDateTextFromTargetAppointment( + this.targetedAppointmentData, + this.isAllDay() ? DateFormatType.DATE : DateFormatType.TIME, + ); + + return dateText; + } + + protected isRecurring(): boolean { + const dataAccessor = this.option().getDataAccessor(); + const recurrenceRule = dataAccessor.get('recurrenceRule', this.targetedAppointmentData); + + return Boolean(recurrenceRule); + } + + protected isAllDay(): boolean { + const dataAccessor = this.option().getDataAccessor(); + const allDay = dataAccessor.get('allDay', this.targetedAppointmentData); + + return Boolean(allDay); + } + + private renderContentTemplate(): void { + const $content = $('
') + .addClass(APPOINTMENT_CLASSES.CONTENT) + .appendTo(this.$element()); + + const template = this.option().appointmentTemplate instanceof EmptyTemplate + ? this.defaultAppointmentTemplate + : this.option().appointmentTemplate; + + const $renderPromise = template.render({ + container: getPublicElement($content), + model: { + appointmentData: this.appointmentData, + targetedAppointmentData: this.targetedAppointmentData, + }, + }); + + when($renderPromise).done(() => { + this.option().onAppointmentRendered({ + element: getPublicElement(this.$element()), + appointmentData: this.appointmentData, + targetedAppointmentData: this.targetedAppointmentData, + }); + }); + } + + protected defaultAppointmentContent($container: dxElementWrapper): dxElementWrapper { + return $container; + } +} + +// TODO: rename to dxSchedulerAppointment when old impl is removed +// eslint-disable-next-line @typescript-eslint/no-explicit-any +registerComponent('dxSchedulerNewAppointment', BaseAppointmentView as any); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts new file mode 100644 index 000000000000..8e3df06770f7 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts @@ -0,0 +1,221 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import type { SafeAppointment } from '@ts/scheduler/types'; + +import fx from '../../../common/core/animation/fx'; +import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; +import type { GridAppointmentViewProperties } from './grid_appointment'; +import { GridAppointmentView } from './grid_appointment'; + +const getProperties = ( + appointmentData: SafeAppointment, +): GridAppointmentViewProperties => { + const baseProperties = getBaseAppointmentProperties(appointmentData); + + return { + ...baseProperties, + geometry: { + top: 0, left: 0, width: 0, height: 0, + }, + modifiers: { + empty: false, + }, + }; +}; + +const createGridAppointment = async ( + properties: GridAppointmentViewProperties, +): Promise => { + const $element = $('.root'); + + // @ts-expect-error + const instance = new GridAppointmentView($element, properties); + + // Await for resources + await new Promise(process.nextTick); + + return instance; +}; + +const defaultAppointmentData = { + title: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe('GridAppointment', () => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + document.body.innerHTML = ''; + fx.off = false; + jest.useRealTimers(); + }); + + describe('Classes', () => { + it('should have container class', async () => { + const instance = await createGridAppointment( + getProperties(defaultAppointmentData), + ); + + expect(instance.$element().hasClass(APPOINTMENT_CLASSES.CONTAINER)).toBe(true); + }); + + it.each([ + true, false, + ])('should have correct empty class for modifiers.empty = %o', async (empty) => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + modifiers: { empty }, + }); + + expect(instance.$element().hasClass(APPOINTMENT_TYPE_CLASSES.EMPTY)).toBe(empty); + }); + }); + + describe('Title', () => { + it.each([ + { text: 'Test title', expected: 'Test title' }, + { text: undefined, expected: '(No subject)' }, + { text: '', expected: '(No subject)' }, + ])('should have correct title text for appointment text = %o', async ({ text, expected }) => { + const instance = await createGridAppointment( + getProperties({ + ...defaultAppointmentData, + text, + }), + ); + + const $title = instance.$element().find(`.${APPOINTMENT_CLASSES.TITLE}`); + + expect($title.text()).toBe(expected); + }); + }); + + describe('Date text', () => { + it('should have correct date text', async () => { + const instance = await createGridAppointment( + getProperties({ + ...defaultAppointmentData, + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), + }), + ); + + const $date = instance.$element().find(`.${APPOINTMENT_CLASSES.DATE}`); + + expect($date.text()).toBe('9:00 AM - 10:00 AM'); + }); + }); + + describe('Geometry', () => { + it('should apply geometry on init', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + geometry: { + top: 10, left: 15, width: 100, height: 50, + }, + }); + + const $element = instance.$element(); + + expect($element.css('top')).toBe('10px'); + expect($element.css('left')).toBe('15px'); + expect($element.css('width')).toBe('100px'); + expect($element.css('height')).toBe('50px'); + }); + + it('should apply new geometry when resize() is called', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + geometry: { + top: 10, left: 15, width: 100, height: 50, + }, + }); + + instance.option('geometry', { + top: 20, + left: 25, + width: 150, + height: 70, + }); + + instance.resize(); + + const $element = instance.$element(); + + expect($element.css('top')).toBe('20px'); + expect($element.css('left')).toBe('25px'); + expect($element.css('width')).toBe('150px'); + expect($element.css('height')).toBe('70px'); + }); + }); + + describe('Recurrence', () => { + it.each([ + true, false, + ])('should have correct recurrence icon visibility for isRecurring = %o', async (isRecurring) => { + const instance = await createGridAppointment( + getProperties({ + ...defaultAppointmentData, + recurrenceRule: isRecurring ? 'FREQ=DAILY' : undefined, + }), + ); + + const $icon = instance.$element().find(`.${APPOINTMENT_CLASSES.RECURRENCE_ICON}`); + + expect($icon.length).toBe(isRecurring ? 1 : 0); + }); + }); + + describe('All day', () => { + it.each([ + true, false, + ])('should have correct all day text visibility for allDay = %o', async (allDay) => { + const instance = await createGridAppointment( + getProperties({ + ...defaultAppointmentData, + allDay, + }), + ); + + const $allDayText = instance.$element().find(`.${APPOINTMENT_CLASSES.ALL_DAY_TEXT}`); + + expect($allDayText.length).toBe(allDay ? 1 : 0); + }); + }); + + describe('Resources', () => { + it('should apply resource color', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + getResourceColor: () => Promise.resolve('red'), + }); + + expect(instance.$element().css('backgroundColor')).toBe('red'); + }); + + it('should not have background-color css when no resource', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + getResourceColor: () => Promise.resolve(undefined), + }); + + expect(instance.$element().css('backgroundColor')).toBe(''); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts new file mode 100644 index 000000000000..f8388dff4de3 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts @@ -0,0 +1,90 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; + +import { + ALL_DAY_TEXT, APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, RECURRING_LABEL, +} from '../const'; +import type { BaseAppointmentViewProperties } from './base_appointment'; +import { BaseAppointmentView } from './base_appointment'; + +export interface GridAppointmentViewProperties extends BaseAppointmentViewProperties { + geometry: { + height: number; + width: number; + top: number; + left: number; + }; + modifiers: { + empty: boolean; + }; +} + +export class GridAppointmentView extends BaseAppointmentView { + override _initMarkup(): void { + super._initMarkup(); + + // eslint-disable-next-line no-void + void this.applyElementColor(); + } + + public override resize(): void { + const { geometry } = this.option(); + + this.$element().css({ + height: geometry.height, + width: geometry.width, + top: geometry.top, + left: geometry.left, + }); + } + + protected override applyElementClasses(): void { + super.applyElementClasses(); + + this.$element() + .toggleClass(APPOINTMENT_TYPE_CLASSES.EMPTY, this.option().modifiers.empty); + } + + protected override defaultAppointmentContent( + $container: dxElementWrapper, + ): dxElementWrapper { + $('
') + .text(this.getTitleText()) + .addClass(APPOINTMENT_CLASSES.TITLE) + .appendTo($container); + + if (this.isRecurring()) { + $('') + .addClass(`${APPOINTMENT_CLASSES.RECURRENCE_ICON} dx-icon-repeat`) + .attr('aria-label', RECURRING_LABEL) + .attr('role', 'img') + .appendTo($container); + } + + const $contentDetails = $('
') + .addClass(APPOINTMENT_CLASSES.DETAILS) + .appendTo($container); + + $('
') + .addClass(APPOINTMENT_CLASSES.DATE) + .text(this.getDateText()) + .appendTo($contentDetails); + + if (this.isAllDay()) { + $('
') + .text(ALL_DAY_TEXT) + .addClass(APPOINTMENT_CLASSES.ALL_DAY_TEXT) + .prependTo($contentDetails); + } + + return $container; + } + + private async applyElementColor(): Promise { + const color = await this.option().getResourceColor(); + + if (color) { + this.$element().css('backgroundColor', color); + } + } +} diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts new file mode 100644 index 000000000000..7e9cc8afe6da --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts @@ -0,0 +1,186 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; + +import fx from '../../../common/core/animation/fx'; +import type { SafeAppointment, TargetedAppointment } from '../types'; +import type { AppointmentCollectorProperties } from './appointment_collector'; +import { AppointmentCollector } from './appointment_collector'; +import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; + +const getProperties = ( + appointmentData: SafeAppointment, +): AppointmentCollectorProperties => { + const targetedAppointmentData: TargetedAppointment = { + ...appointmentData, + displayStartDate: appointmentData.startDate as Date, + displayEndDate: appointmentData.endDate as Date, + }; + + return { + appointmentsCount: 1, + isCompact: false, + geometry: { + height: 30, + width: 30, + top: 0, + left: 0, + }, + targetedAppointmentData, + appointmentCollectorTemplate: new EmptyTemplate(), + }; +}; + +const createAppointmentCollector = ( + properties: AppointmentCollectorProperties, +): AppointmentCollector => { + const $element = $('.root'); + + // @ts-expect-error + return new AppointmentCollector($element, properties); +}; + +const defaultAppointmentData = { + title: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe('AppointmentCollector', () => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + fx.off = false; + jest.useRealTimers(); + }); + + describe('Classes', () => { + it('should have correct container class', () => { + const instance = createAppointmentCollector( + getProperties(defaultAppointmentData), + ); + + expect(instance.$element().hasClass('dx-scheduler-appointment-collector')).toBe(true); + expect(instance.$element().hasClass('dx-button')).toBe(true); + }); + + it('should have correct content class', () => { + const instance = createAppointmentCollector( + getProperties(defaultAppointmentData), + ); + const $buttonContent = instance.$element().find('.dx-button-content'); + + expect($buttonContent.hasClass(APPOINTMENT_COLLECTOR_CLASSES.CONTENT)).toBe(true); + }); + + it.each([ + true, false, + ])('should have correct compact class for isCompact = %o', (isCompact) => { + const instance = createAppointmentCollector({ + ...getProperties(defaultAppointmentData), + isCompact, + }); + + expect(instance.$element().hasClass(APPOINTMENT_COLLECTOR_CLASSES.COMPACT)).toBe(isCompact); + }); + }); + + describe('Aria', () => { + it('should have correct aria-roledescription when appointment is in the same date', () => { + const instance = createAppointmentCollector( + getProperties({ + text: 'test', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), + }), + ); + + expect(instance.$element().attr('aria-roledescription')).toBe('January 1, 2024'); + }); + + it('should have correct aria-roledescription when appointment is in different dates', () => { + const instance = createAppointmentCollector( + getProperties({ + text: 'test', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 2, 10, 0), + }), + ); + + expect(instance.$element().attr('aria-roledescription')).toBe('January 1, 2024 - January 2, 2024'); + }); + }); + + describe('Geometry', () => { + it('should have correct top and left on init', () => { + const instance = createAppointmentCollector({ + ...getProperties(defaultAppointmentData), + geometry: { + top: 100, + left: 200, + height: 30, + width: 40, + }, + }); + + expect(instance.$element().css('top')).toBe('100px'); + expect(instance.$element().css('left')).toBe('200px'); + expect(instance.$element().css('height')).toBe('30px'); + expect(instance.$element().css('width')).toBe('40px'); + }); + + it('should have correct top and left after geometry is updated and resize is called', () => { + const instance = createAppointmentCollector({ + ...getProperties(defaultAppointmentData), + geometry: { + top: 100, + left: 200, + height: 30, + width: 40, + }, + }); + + instance.option('geometry', { + height: 30, + width: 40, + top: 150, + left: 250, + }); + instance.resize(); + + expect(instance.$element().css('top')).toBe('150px'); + expect(instance.$element().css('left')).toBe('250px'); + expect(instance.$element().css('height')).toBe('30px'); + expect(instance.$element().css('width')).toBe('40px'); + }); + }); + + describe('Text', () => { + it.each([ + { isCompact: true, expectedText: '1' }, + { isCompact: false, expectedText: '1 more' }, + ])('should have correct text for appointmentsCount = 1 and isCompact = %o', ({ isCompact, expectedText }) => { + const instance = createAppointmentCollector({ + ...getProperties(defaultAppointmentData), + appointmentsCount: 1, + isCompact, + }); + const $buttonContent = instance.$element().find('.dx-button-content'); + + expect($buttonContent.text()).toBe(expectedText); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts new file mode 100644 index 000000000000..8b5599c965b8 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts @@ -0,0 +1,122 @@ +import dateLocalization from '@js/common/core/localization/date'; +import messageLocalization from '@js/common/core/localization/message'; +import registerComponent from '@js/core/component_registrator'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { EmptyTemplate } from '@js/core/templates/empty_template'; +import Button from '@js/ui/button'; +import { FunctionTemplate } from '@ts/core/templates/m_function_template'; +import type { TemplateBase } from '@ts/core/templates/m_template_base'; +import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; +import DOMComponent from '@ts/core/widget/dom_component'; +import type { TargetedAppointment } from '@ts/scheduler/types'; + +import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; + +export interface AppointmentCollectorProperties + extends DOMComponentProperties { + appointmentsCount: number; + isCompact: boolean; + geometry: { + height: number; + width: number; + top: number; + left: number; + }; + targetedAppointmentData: TargetedAppointment; + appointmentCollectorTemplate: TemplateBase; +} + +export class AppointmentCollector + extends DOMComponent { + private defaultAppointmentCollectorTemplate!: FunctionTemplate; + + private buttonInstance?: Button; + + override _init(): void { + super._init(); + + this.defaultAppointmentCollectorTemplate = new FunctionTemplate((options) => { + this.defaultAppointmentCollectorContent($(options.container)); + }); + } + + override _initMarkup(): void { + super._initMarkup(); + + this.applyElementClasses(); + this.applyElementAria(); + this.resize(); + this.renderContentTemplate(); + } + + public resize(): void { + this.$element().css({ + top: this.option().geometry.top, + left: this.option().geometry.left, + }); + + this.buttonInstance?.option({ + width: this.option().geometry.width, + height: this.option().geometry.height, + }); + } + + private applyElementClasses(): void { + this.$element() + .addClass(APPOINTMENT_COLLECTOR_CLASSES.CONTAINER) + .toggleClass(APPOINTMENT_COLLECTOR_CLASSES.COMPACT, this.option().isCompact); + } + + private applyElementAria(): void { + const localizeDate = (date: Date): string => + // eslint-disable-next-line @stylistic/implicit-arrow-linebreak + `${dateLocalization.format(date, 'monthAndDay')}, ${dateLocalization.format(date, 'year')}`; + + const { targetedAppointmentData } = this.option(); + + const startDateText = localizeDate(targetedAppointmentData.displayStartDate); + const endDateText = localizeDate(targetedAppointmentData.displayEndDate); + + const dateText = startDateText === endDateText + ? startDateText + : `${startDateText} - ${endDateText}`; + + this.$element() + .attr('aria-roledescription', dateText); + } + + private renderContentTemplate(): void { + const template = this.option().appointmentCollectorTemplate instanceof EmptyTemplate + ? this.defaultAppointmentCollectorTemplate + : this.option().appointmentCollectorTemplate; + + this.buttonInstance = this._createComponent(this.$element(), Button, { + type: 'default', + width: this.option().geometry.width, + height: this.option().geometry.height, + template, + }); + } + + private defaultAppointmentCollectorContent( + $container: dxElementWrapper, + ): dxElementWrapper { + const count = this.option().appointmentsCount; + const text = this.option().isCompact + ? count + // eslint-disable-next-line @typescript-eslint/no-explicit-any + : (messageLocalization.getFormatter('dxScheduler-moreAppointments') as any)(count); + + $('') + .text(text) + .appendTo($container); + + $container.addClass(APPOINTMENT_COLLECTOR_CLASSES.CONTENT); + + return $container; + } +} + +// eslint-disable-next-line +registerComponent('dxSchedulerNewAppointmentCollector', AppointmentCollector as any); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts new file mode 100644 index 000000000000..583af84d298e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -0,0 +1,395 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; + +import fx from '../../../common/core/animation/fx'; +import { mockAppointmentDataAccessor } from '../__mock__/appointment_data_accessor.mock'; +import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; +import type { ResourceConfig } from '../utils/loader/types'; +import type { AppointmentDataSource } from '../view_model/m_appointment_data_source'; +import { + mockAgendaViewModel, + mockAppointmentCollectorViewModel, + mockGridViewModel, +} from './__mock__/appointment_view_model'; +import type { AppointmentsProperties } from './appointments'; +import { Appointments } from './appointments'; +import { + APPOINTMENT_CLASSES, + APPOINTMENT_COLLECTOR_CLASSES, + APPOINTMENTS_CONTAINER_CLASS, +} from './const'; + +const mockAppointmentDataSource = (): AppointmentDataSource => ({ + getUpdatedAppointment: () => null, + getUpdatedAppointmentKeys: () => [], +} as unknown as AppointmentDataSource); + +const getProperties = (options: { + resources?: ResourceConfig[]; +} = {}): AppointmentsProperties => ({ + getAppointmentDataSource: mockAppointmentDataSource, + getResourceManager: () => getResourceManagerMock(options.resources ?? []), + getDataAccessor: () => mockAppointmentDataAccessor, + currentView: 'week', +} as AppointmentsProperties); + +const createAppointments = ( + properties?: AppointmentsProperties, +): Appointments => { + const $element = $('.root'); + + // @ts-expect-error + return new Appointments($element, properties); +}; + +const defaultAppointmentData = { + text: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe('Appointments', () => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + + $('
') + .addClass('allday-container') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + fx.off = false; + jest.useRealTimers(); + }); + + describe('Classes', () => { + it('should have correct container class', () => { + const instance = createAppointments(getProperties()); + + expect(instance.$element().hasClass(APPOINTMENTS_CONTAINER_CLASS)).toBe(true); + }); + }); + + describe('Rendering', () => { + it('should render view model with grid appointments', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + }); + + it('should render view model with agenda appointments', () => { + const instance = createAppointments({ + ...getProperties(), + currentView: 'agenda', + }); + instance.option('viewModel', [ + mockAgendaViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + }); + + it('should render view model with appointment collectors', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockAppointmentCollectorViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(instance.$element().find(`.${APPOINTMENT_COLLECTOR_CLASSES.CONTAINER}`).length).toBe(1); + }); + + it('should rerender all appointments when view model is completely changed', () => { + const data1 = { ...defaultAppointmentData }; + const data2 = { ...defaultAppointmentData, text: 'Appointment 2' }; + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel(data1, { sortedIndex: 0 }), + mockGridViewModel(data2, { sortedIndex: 1 }), + ]); + + const elementsBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(elementsBefore.length).toBe(2); + + const data3 = { ...defaultAppointmentData, text: 'Appointment 3' }; + const data4 = { ...defaultAppointmentData, text: 'Appointment 4' }; + instance.option('viewModel', [ + mockGridViewModel(data3, { sortedIndex: 0 }), + mockGridViewModel(data4, { sortedIndex: 1 }), + ]); + + const elementsAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(elementsAfter.length).toBe(2); + expect(elementsAfter[0]).not.toBe(elementsBefore[0]); + expect(elementsAfter[1]).not.toBe(elementsBefore[1]); + }); + + it('should render allDay appointment to the allDay container', () => { + const $allDayContainer = $('.allday-container'); + + const instance = createAppointments({ + ...getProperties(), + $allDayContainer, + }); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData, allDay: true }, { sortedIndex: 0 }), + ]); + + expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); + expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + }); + + it('should not render allDay agenda appointment to the allDay container', () => { + const $allDayContainer = $('.allday-container'); + + const instance = createAppointments({ + ...getProperties(), + $allDayContainer, + currentView: 'agenda', + }); + instance.option('viewModel', [ + mockAgendaViewModel({ ...defaultAppointmentData, allDay: true }, { sortedIndex: 0 }), + ]); + + expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); + }); + }); + + describe('Partial rendering', () => { + it('should render only changed appointments if appointment is added', () => { + const data1 = { ...defaultAppointmentData }; + const data2 = { ...defaultAppointmentData, text: 'Appointment 2' }; + const item1 = mockGridViewModel(data1, { sortedIndex: 0 }); + const item2 = mockGridViewModel(data2, { sortedIndex: 1 }); + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [item1, item2]); + + const appointmentsBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsBefore.length).toBe(2); + + const data3 = { ...defaultAppointmentData, text: 'Appointment 3' }; + const item3 = mockGridViewModel(data3, { sortedIndex: 2 }); + instance.option('viewModel', [item1, item2, item3]); + + const appointmentsAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsAfter.length).toBe(3); + expect(appointmentsAfter[0]).toBe(appointmentsBefore[0]); + expect(appointmentsAfter[1]).toBe(appointmentsBefore[1]); + }); + + it('should render only changed appointments if appointment is removed', () => { + const data1 = { ...defaultAppointmentData }; + const data2 = { ...defaultAppointmentData, text: 'Appointment 2' }; + const data3 = { ...defaultAppointmentData, text: 'Appointment 3' }; + const item1 = mockGridViewModel(data1, { sortedIndex: 0 }); + const item2 = mockGridViewModel(data2, { sortedIndex: 1 }); + const item3 = mockGridViewModel(data3, { sortedIndex: 2 }); + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [item1, item2, item3]); + + const appointmentsBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsBefore.length).toBe(3); + + instance.option('viewModel', [item1, item3]); + + const appointmentsAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsAfter.length).toBe(2); + expect(appointmentsAfter[0]).toBe(appointmentsBefore[0]); + expect(appointmentsAfter[1]).toBe(appointmentsBefore[2]); + }); + + it('should rerender one appointment when its view model changed', () => { + const data1 = { ...defaultAppointmentData }; + const data2 = { ...defaultAppointmentData, text: 'Appointment 2' }; + const item1 = mockGridViewModel(data1, { sortedIndex: 0 }); + const item2 = mockGridViewModel(data2, { sortedIndex: 1 }); + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [item1, item2]); + + const appointmentsBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsBefore.length).toBe(2); + + const item2Changed = mockGridViewModel(data2, { sortedIndex: 1, groupIndex: 1 }); + instance.option('viewModel', [item1, item2Changed]); + + const appointmentsAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsAfter.length).toBe(2); + expect(appointmentsAfter[0]).toBe(appointmentsBefore[0]); + expect(appointmentsAfter[1]).not.toBe(appointmentsBefore[1]); + }); + + it('should rerender several appointments when their view models changed', () => { + const data0 = { ...defaultAppointmentData }; + const data1 = { ...defaultAppointmentData, text: 'Appointment 1' }; + const data2 = { ...defaultAppointmentData, text: 'Appointment 2' }; + const item0 = mockGridViewModel(data0, { sortedIndex: 0 }); + const item1 = mockGridViewModel(data1, { sortedIndex: 1 }); + const item2 = mockGridViewModel(data2, { sortedIndex: 2 }); + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [item0, item1, item2]); + + const appointmentsBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsBefore.length).toBe(3); + + const item1Changed = mockGridViewModel(data1, { sortedIndex: 1, groupIndex: 1 }); + const item2Changed = mockGridViewModel(data2, { sortedIndex: 2, groupIndex: 1 }); + instance.option('viewModel', [item0, item1Changed, item2Changed]); + + const appointmentsAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).toArray(); + expect(appointmentsAfter.length).toBe(3); + expect(appointmentsAfter[0]).toBe(appointmentsBefore[0]); + expect(appointmentsAfter[1]).not.toBe(appointmentsBefore[1]); + expect(appointmentsAfter[2]).not.toBe(appointmentsBefore[2]); + }); + + it('should resize appointment if its size changed', () => { + const data = { ...defaultAppointmentData }; + const item = mockGridViewModel(data, { + sortedIndex: 0, top: 10, left: 10, height: 50, width: 100, + }); + + const instance = createAppointments(getProperties()); + instance.option('viewModel', [item]); + + const elementBefore = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).get(0); + expect($(elementBefore).css('top')).toBe('10px'); + + const itemResized = mockGridViewModel(data, { + sortedIndex: 0, top: 20, left: 20, height: 50, width: 100, + }); + instance.option('viewModel', [itemResized]); + + const elementAfter = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).get(0); + expect(elementAfter).toBe(elementBefore); + expect($(elementAfter).css('top')).toBe('20px'); + expect($(elementAfter).css('left')).toBe('20px'); + }); + }); + + describe('Resources', () => { + it('should apply resource color', async () => { + const instance = createAppointments({ + ...getProperties({ + resources: [{ + fieldExpr: 'roomId', + dataSource: [{ text: 'Room 1', id: 1, color: 'red' }], + }], + }), + }); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData, roomId: 1 }, { sortedIndex: 0 }), + ]); + + await new Promise(process.nextTick); + + const $appointment = instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).first(); + expect($appointment.css('backgroundColor')).toBe('red'); + }); + }); + + describe('onAppointmentRendered', () => { + it('should be called with correct arguments when grid appointment is rendered', () => { + const onAppointmentRendered = jest.fn(); + const instance = createAppointments({ + ...getProperties(), + onAppointmentRendered, + }); + instance.option('viewModel', [ + mockGridViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(onAppointmentRendered).toHaveBeenCalledTimes(1); + expect(onAppointmentRendered).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: defaultAppointmentData, + targetedAppointmentData: expect.objectContaining({ + text: defaultAppointmentData.text, + }), + }), + ); + }); + + it('should be called with correct arguments when agenda appointment is rendered', () => { + const onAppointmentRendered = jest.fn(); + const instance = createAppointments({ + ...getProperties(), + onAppointmentRendered, + }); + instance.option('viewModel', [ + mockAgendaViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(onAppointmentRendered).toHaveBeenCalledTimes(1); + expect(onAppointmentRendered).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: defaultAppointmentData, + targetedAppointmentData: expect.objectContaining({ + text: defaultAppointmentData.text, + }), + }), + ); + }); + + it('should not be called when appointment collector is rendered', () => { + const onAppointmentRendered = jest.fn(); + const instance = createAppointments({ + ...getProperties(), + onAppointmentRendered, + }); + instance.option('viewModel', [ + mockAppointmentCollectorViewModel(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + expect(onAppointmentRendered).not.toHaveBeenCalled(); + }); + + it('should be called several times when several appointments are rendered', () => { + const onAppointmentRendered = jest.fn(); + const instance = createAppointments({ + ...getProperties(), + onAppointmentRendered, + }); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData, text: 'Appointment 1' }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData, text: 'Appointment 2' }, { sortedIndex: 1 }), + ]); + + expect(onAppointmentRendered).toHaveBeenCalledTimes(2); + expect(onAppointmentRendered).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + }), + }), + ); + expect(onAppointmentRendered).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 2', + }), + }), + ); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts new file mode 100644 index 000000000000..bd31eae27d23 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -0,0 +1,320 @@ +import registerComponent from '@js/core/component_registrator'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { Properties as SchedulerProperties } from '@js/ui/scheduler'; +import { domAdapter } from '@ts/core/m_dom_adapter'; +import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; +import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; +import DOMComponent from '@ts/core/widget/dom_component'; +import type { OptionChanged } from '@ts/core/widget/types'; + +import type { SafeAppointment, TargetedAppointment, ViewType } from '../types'; +import type { AppointmentDataAccessor } from '../utils/data_accessor/appointment_data_accessor'; +import type { AppointmentResource } from '../utils/resource_manager/appointment_groups_utils'; +import type { ResourceManager } from '../utils/resource_manager/resource_manager'; +import type { AppointmentDataSource } from '../view_model/m_appointment_data_source'; +import type { + AppointmentAgendaViewModel, + AppointmentCollectorViewModel, + AppointmentItemViewModel, + AppointmentViewModelPlain, + BaseAppointmentViewModel, +} from '../view_model/types'; +import { AgendaAppointmentView } from './appointment/agenda_appointment'; +import type { BaseAppointmentViewProperties } from './appointment/base_appointment'; +import { GridAppointmentView } from './appointment/grid_appointment'; +import { AppointmentCollector } from './appointment_collector'; +import { APPOINTMENTS_CONTAINER_CLASS } from './const'; +import { getTargetedAppointment } from './utils/get_targeted_appointment'; +import type { DiffItem } from './utils/get_view_model_diff'; +import { getViewModelDiff } from './utils/get_view_model_diff'; +import { isAgendaAppointmentViewModel, isCollectorViewModel as isAppointmentCollectorViewModel, isGridAppointmentViewModel } from './utils/type_helpers'; + +export interface AppointmentsProperties extends DOMComponentProperties { + currentView: ViewType; + viewModel: AppointmentViewModelPlain[]; + items: AppointmentViewModelPlain[]; // TODO: legacy compatibility + $allDayContainer: dxElementWrapper | null; + + appointmentTemplate: SchedulerProperties['appointmentTemplate']; + appointmentCollectorTemplate: SchedulerProperties['appointmentCollectorTemplate']; + + onAppointmentRendered: BaseAppointmentViewProperties['onAppointmentRendered']; + + getAppointmentDataSource: () => AppointmentDataSource; + getResourceManager: () => ResourceManager; + getDataAccessor: () => AppointmentDataAccessor; +} + +type AppointmentComponent = GridAppointmentView | AgendaAppointmentView | AppointmentCollector; + +export class Appointments extends DOMComponent { + private appointmentBySortIndex: Record = {}; + + private get $allDayContainer(): dxElementWrapper | null { + return this.option().$allDayContainer; + } + + private get $commonContainer(): dxElementWrapper { + return this.$element(); + } + + override _init(): void { + super._init(); + + this._templateManager.addDefaultTemplates({ + appointment: new EmptyTemplate(), + appointmentCollector: new EmptyTemplate(), + }); + } + + override _initMarkup(): void { + super._initMarkup(); + + this.$element().addClass(APPOINTMENTS_CONTAINER_CLASS); + } + + override _getDefaultOptions(): AppointmentsProperties { + return { + ...super._getDefaultOptions(), + viewModel: [], + $allDayContainer: null, + appointmentTemplate: 'appointment', + appointmentCollectorTemplate: 'appointmentCollector', + onAppointmentRendered: (): void => {}, + }; + } + + override _optionChanged(args: OptionChanged): void { + switch (args.name) { + case 'items': { // TODO: legacy compatibility + this.option('viewModel', args.value); + break; + } + case 'viewModel': { + if (this.option().currentView === 'agenda') { + this.renderAgendaAppointments(args.value as AppointmentAgendaViewModel[]); + break; + } + + const diff = this.getViewModelDiff( + (args.value ?? []) as AppointmentItemViewModel[] | AppointmentCollectorViewModel[], + args.previousValue ?? [], + ); + this.renderViewModelDiff(diff); + break; + } + default: + break; + } + } + + public updateResizableArea(): void { /* TODO: legacy compatibility */ } + + public moveAppointmentBack(): void { /* TODO: legacy compatibility */ } + + public focus(): void { /* TODO: legacy compatibility */ } + + public _renderAppointmentTemplate(): void { /* TODO: legacy compatibility */ } + + private getAppointmentElement(sortedIndex: number): dxElementWrapper { + return this.appointmentBySortIndex[sortedIndex].$element(); + } + + private getViewModelDiff( + newViewModel: AppointmentItemViewModel[] | AppointmentCollectorViewModel[], + oldViewModel: AppointmentViewModelPlain[], + ): DiffItem[] { + const isPreviousAgenda = oldViewModel.length && isAgendaAppointmentViewModel(oldViewModel[0]); + + const normalizedOldViewModel = isPreviousAgenda + ? [] + : oldViewModel as AppointmentItemViewModel[] | AppointmentCollectorViewModel[]; + + return getViewModelDiff( + normalizedOldViewModel, + newViewModel, + this.option().getAppointmentDataSource(), + ); + } + + private renderAgendaAppointments(appointments: AppointmentViewModelPlain[]): void { + const commonFragment = domAdapter.createDocumentFragment(); + + this.$commonContainer.empty(); + + appointments.forEach((appointmentViewModel) => { + const appointment = this.renderAppointment(commonFragment, appointmentViewModel); + this.appointmentBySortIndex[appointmentViewModel.sortedIndex] = appointment; + }); + + this.$commonContainer.get(0).appendChild(commonFragment); + } + + private renderViewModelDiff(viewModelDiff: DiffItem[]): void { + const allDayFragment = domAdapter.createDocumentFragment(); + const commonFragment = domAdapter.createDocumentFragment(); + + const newAppointmentBySortedIndex: Record = {}; + + const isRepaintAll = viewModelDiff.every( + (item) => Boolean(item.needToAdd ?? item.needToRemove), + ); + + if (isRepaintAll) { + this.$allDayContainer?.empty(); + this.$commonContainer.empty(); + } + + viewModelDiff.forEach((diffItem) => { + const { allDay, sortedIndex } = diffItem.item; + + switch (true) { + case diffItem.needToRemove: { + if (isRepaintAll) { + break; + } + + this.getAppointmentElement(sortedIndex).remove(); + break; + } + case diffItem.needToAdd: { + const fragment = allDay ? allDayFragment : commonFragment; + const appointment = this.renderAppointment(fragment, diffItem.item); + + newAppointmentBySortedIndex[sortedIndex] = appointment; + break; + } + case diffItem.needToResize: { + const appointment = this.appointmentBySortIndex[sortedIndex]; + appointment.option('geometry', { + height: diffItem.item.height, + width: diffItem.item.width, + top: diffItem.item.top, + left: diffItem.item.left, + }); + appointment.resize(); + + newAppointmentBySortedIndex[sortedIndex] = this.appointmentBySortIndex[sortedIndex]; + break; + } + default: + newAppointmentBySortedIndex[sortedIndex] = this.appointmentBySortIndex[sortedIndex]; + } + }); + + this.appointmentBySortIndex = newAppointmentBySortedIndex; + + if (this.$allDayContainer) { + this.$allDayContainer.get(0).appendChild(allDayFragment); + } + this.$commonContainer.get(0).appendChild(commonFragment); + } + + private renderAppointment( + fragment: DocumentFragment, + appointmentViewModel: AppointmentViewModelPlain, + ): AppointmentComponent { + const $element = $('
'); + + fragment.appendChild($element.get(0)); + + const targetedAppointmentData = this.getTargetedAppointmentData(appointmentViewModel); + + if (isAppointmentCollectorViewModel(appointmentViewModel)) { + return this._createComponent($element, AppointmentCollector, { + appointmentsCount: appointmentViewModel.items.length, + isCompact: appointmentViewModel.isCompact, + geometry: { + height: appointmentViewModel.height, + width: appointmentViewModel.width, + top: appointmentViewModel.top, + left: appointmentViewModel.left, + }, + targetedAppointmentData, + appointmentCollectorTemplate: this._getTemplateByOption('appointmentCollectorTemplate'), + }); + } + + const baseConfig: BaseAppointmentViewProperties = { + appointmentTemplate: this._getTemplateByOption('appointmentTemplate'), + appointmentData: appointmentViewModel.itemData, + targetedAppointmentData, + getResourceColor: this.getResourceColor.bind(this, appointmentViewModel), + onAppointmentRendered: this.option().onAppointmentRendered, + getDataAccessor: this.option().getDataAccessor, + }; + + if (isGridAppointmentViewModel(appointmentViewModel)) { + return this._createComponent( + $element, + GridAppointmentView, + { + ...baseConfig, + geometry: { + height: appointmentViewModel.height, + width: appointmentViewModel.width, + top: appointmentViewModel.top, + left: appointmentViewModel.left, + }, + modifiers: { + empty: appointmentViewModel.empty, + }, + }, + ); + } + + return this._createComponent( + $element, + AgendaAppointmentView, + { + ...baseConfig, + modifiers: { + isLastInGroup: appointmentViewModel.isLastInGroup, + }, + geometry: { + height: appointmentViewModel.height, + width: appointmentViewModel.width, + }, + getResourcesValues: this.getResourcesValues.bind(this), + }, + ); + } + + private getTargetedAppointmentData( + appointmentViewModel: AppointmentViewModelPlain, + ): TargetedAppointment { + const normalizedAppointmentViewModel = isAppointmentCollectorViewModel(appointmentViewModel) + ? appointmentViewModel.items[0] + : appointmentViewModel; + + return getTargetedAppointment( + normalizedAppointmentViewModel, + this.option().getDataAccessor(), + this.option().getResourceManager(), + ); + } + + private getResourceColor( + appointmentViewModel: BaseAppointmentViewModel, + ): Promise { + const resourceManager = this.option().getResourceManager(); + + return resourceManager.getAppointmentColor({ + itemData: appointmentViewModel.itemData, + groupIndex: appointmentViewModel.groupIndex, + }); + } + + private getResourcesValues( + appointmentData: SafeAppointment, + ): Promise { + const resourceManager = this.option().getResourceManager(); + + return resourceManager.getAppointmentResourcesValues(appointmentData); + } +} + +// TODO: rename to dxSchedulerAppointments when old impl is removed +// eslint-disable-next-line +registerComponent('dxSchedulerNewAppointments', Appointments as any); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts new file mode 100644 index 000000000000..60e09c8a8aca --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts @@ -0,0 +1,38 @@ +import messageLocalization from '@js/common/core/localization/message'; + +export const ALL_DAY_TEXT = ` ${messageLocalization.format('dxScheduler-allDay')}: `; +export const RECURRING_LABEL = messageLocalization.format('dxScheduler-appointmentAriaLabel-recurring'); + +export const APPOINTMENTS_CONTAINER_CLASS = 'dx-scheduler-scrollable-appointments'; + +export const APPOINTMENT_COLLECTOR_CLASSES = { + CONTAINER: 'dx-scheduler-appointment-collector', + COMPACT: 'dx-scheduler-appointment-collector-compact', + CONTENT: 'dx-scheduler-appointment-collector-content', +}; + +export const APPOINTMENT_TYPE_CLASSES = { + EMPTY: 'dx-scheduler-appointment-empty', + ALL_DAY: 'dx-scheduler-all-day-appointment', + RECURRING: 'dx-scheduler-appointment-recurrence', +}; + +export const APPOINTMENT_CLASSES = { + CONTAINER: 'dx-scheduler-appointment', + CONTENT: 'dx-scheduler-appointment-content', + ARIA_DESCRIPTION: 'dx-scheduler-appointment-aria-description', + STRIP: 'dx-scheduler-appointment-strip', + TITLE: 'dx-scheduler-appointment-title', + RECURRENCE_ICON: 'dx-scheduler-appointment-recurrence-icon', + DETAILS: 'dx-scheduler-appointment-content-details', + DATE: 'dx-scheduler-appointment-content-date', + ALL_DAY_TEXT: 'dx-scheduler-appointment-content-allday', +}; + +export const AGENDA_APPOINTMENT_CLASSES = { + LAST_IN_DATE: 'dx-scheduler-last-in-date-agenda-appointment', + MARKER: 'dx-scheduler-agenda-appointment-marker', + RESOURCE_LIST: 'dx-scheduler-appointment-resource-list', + RESOURCE_ITEM: 'dx-scheduler-appointment-resource-item', + RESOURCE_ITEM_VALUE: 'dx-scheduler-appointment-resource-item-value', +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_date_text.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_date_text.ts new file mode 100644 index 000000000000..4e0d893da903 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_date_text.ts @@ -0,0 +1,60 @@ +import dateLocalization from '@js/common/core/localization/date'; +import dateUtils from '@js/core/utils/date'; + +import type { TargetedAppointment, ViewType } from '../../types'; + +export enum DateFormatType { + DATETIME = 'DATETIME', + TIME = 'TIME', + DATE = 'DATE', +} + +export const getDateFormatType = ( + startDate: Date, + endDate: Date, + isAllDay?: boolean, + viewType?: ViewType, +): DateFormatType => { + if (isAllDay) { + return DateFormatType.DATE; + } + if (viewType !== 'month' && dateUtils.sameDate(startDate, endDate)) { + return DateFormatType.TIME; + } + return DateFormatType.DATETIME; +}; + +export const getDateText = (startDate: Date, endDate: Date, formatType: DateFormatType): string => { + const dateFormat = 'monthandday'; + const timeFormat = 'shorttime'; + const isSameDate = dateUtils.sameDate(startDate, endDate); + + switch (formatType) { + case DateFormatType.DATETIME: + return [ + dateLocalization.format(startDate, dateFormat), + ' ', + dateLocalization.format(startDate, timeFormat), + ' - ', + isSameDate ? '' : `${dateLocalization.format(endDate, dateFormat)} `, + dateLocalization.format(endDate, timeFormat), + ].join(''); + case DateFormatType.TIME: + return `${dateLocalization.format(startDate, timeFormat)} - ${dateLocalization.format(endDate, timeFormat)}`; + case DateFormatType.DATE: + return `${dateLocalization.format(startDate, dateFormat)}${isSameDate ? '' : ` - ${dateLocalization.format(endDate, dateFormat)}`}`; + default: + return ''; + } +}; + +export const getDateTextFromTargetAppointment = ( + targetedAppointmentData: TargetedAppointment, + format?: DateFormatType, + viewType?: ViewType, +): string => { + const { displayStartDate: startDate, displayEndDate: endDate, allDay } = targetedAppointmentData; + const formatType = format ?? getDateFormatType(startDate, endDate, allDay, viewType); + + return getDateText(startDate, endDate, formatType); +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.test.ts new file mode 100644 index 000000000000..66518914bc48 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.test.ts @@ -0,0 +1,130 @@ +import { + describe, expect, it, +} from '@jest/globals'; +import { mockAppointmentDataAccessor } from '@ts/scheduler/__mock__/appointment_data_accessor.mock'; +import { getResourceManagerMock } from '@ts/scheduler/__mock__/resource_manager.mock'; + +import { getTargetedAppointment } from './get_targeted_appointment'; + +const appointment = { + startDate: new Date(200, 0, 0), + endDate: new Date(200, 0, 1), +}; + +const recurringAppointment = { + ...appointment, + recurrenceRule: 'FREQ=DAILY', +}; + +const info = { + sourceAppointment: { + startDate: new Date(200, 0, 5), + endDate: new Date(200, 0, 6), + }, + appointment: { + startDate: new Date(200, 0, 5, 10), + endDate: new Date(200, 0, 6, 11), + }, +}; + +describe('getTargetedAppointment', () => { + it('should return grid item targeted appointment', () => { + expect(getTargetedAppointment( + { + itemData: recurringAppointment, + info, + groupIndex: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + mockAppointmentDataAccessor, + getResourceManagerMock([]), + )).toEqual({ + ...recurringAppointment, + startDate: new Date(200, 0, 5), + endDate: new Date(200, 0, 6), + displayStartDate: new Date(200, 0, 5, 10), + displayEndDate: new Date(200, 0, 6, 11), + }); + }); + + it('should return grid item targeted appointment with resources', async () => { + const resourceManager = getResourceManagerMock(); + await resourceManager.loadGroupResources(['roomId', 'assigneeId']); + + expect(getTargetedAppointment( + { + itemData: recurringAppointment, + info, + groupIndex: 5, // 0,1; 0,2; 0,3; 0,4; 1,1; 1,2; <- 5 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + mockAppointmentDataAccessor, + resourceManager, + )).toEqual({ + ...recurringAppointment, + assigneeId: [2], + roomId: 1, + startDate: new Date(200, 0, 5), + endDate: new Date(200, 0, 6), + displayStartDate: new Date(200, 0, 5, 10), + displayEndDate: new Date(200, 0, 6, 11), + }); + }); + + it('should return agenda item targeted partial dates', () => { + expect(getTargetedAppointment( + { + isAgendaModel: true, + itemData: recurringAppointment, + info: { + ...info, + partialDates: { + startDate: new Date(200, 0, 5, 3), + endDate: new Date(200, 0, 7), + }, + }, + groupIndex: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + mockAppointmentDataAccessor, + getResourceManagerMock([]), + )).toEqual({ + ...recurringAppointment, + startDate: new Date(200, 0, 5), + endDate: new Date(200, 0, 6), + displayStartDate: new Date(200, 0, 5, 3), + displayEndDate: new Date(200, 0, 7), + }); + }); + + it('should return agenda item targeted partial dates with resources', async () => { + const resourceManager = getResourceManagerMock(); + await resourceManager.loadGroupResources(['roomId', 'assigneeId']); + + expect(getTargetedAppointment( + { + isAgendaModel: true, + itemData: appointment, + info: { + ...info, + partialDates: { + startDate: new Date(200, 0, 5, 3), + endDate: new Date(200, 0, 7), + }, + }, + groupIndex: 3, // 0,1; 0,2; 0,3; 0,4; <- 3 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + mockAppointmentDataAccessor, + resourceManager, + )).toEqual({ + ...appointment, + assigneeId: [4], + roomId: 0, + startDate: new Date(200, 0, 5), + endDate: new Date(200, 0, 6), + displayStartDate: new Date(200, 0, 5, 3), + displayEndDate: new Date(200, 0, 7), + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.ts new file mode 100644 index 000000000000..bc8de392d08d --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_targeted_appointment.ts @@ -0,0 +1,47 @@ +import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; +import { setAppointmentGroupValues } from '@ts/scheduler/utils/resource_manager/appointment_groups_utils'; +import { getLeafGroupValues } from '@ts/scheduler/utils/resource_manager/group_utils'; +import type { ResourceManager } from '@ts/scheduler/utils/resource_manager/resource_manager'; + +import type { TargetedAppointment } from '../../types'; +import type { + AppointmentAgendaViewModel, + AppointmentItemViewModel, +} from '../../view_model/types'; + +const setTargetedAppointmentResources = ( + targetedAppointment: TargetedAppointment, + appointmentViewModel: AppointmentItemViewModel | AppointmentAgendaViewModel, + resourceManager: ResourceManager, +): void => { + const { groups, resourceById, groupsLeafs } = resourceManager; + if (groups.length) { + const cellGroups = getLeafGroupValues(groupsLeafs, appointmentViewModel.groupIndex); + setAppointmentGroupValues(targetedAppointment, resourceById, cellGroups); + } +}; + +export const getTargetedAppointment = ( + appointmentViewModel: AppointmentItemViewModel | AppointmentAgendaViewModel, + dataAccessor: AppointmentDataAccessor, + resourceManager: ResourceManager, +): TargetedAppointment => { + const { info, itemData } = appointmentViewModel; + + const displayDates = 'partialDates' in info + ? info.partialDates + : info.appointment; + + const targetedAppointment: TargetedAppointment = { + ...itemData, + displayStartDate: new Date(displayDates.startDate), + displayEndDate: new Date(displayDates.endDate), + }; + + dataAccessor.set('startDate', targetedAppointment, new Date(info.sourceAppointment.startDate)); + dataAccessor.set('endDate', targetedAppointment, new Date(info.sourceAppointment.endDate)); + + setTargetedAppointmentResources(targetedAppointment, appointmentViewModel, resourceManager); + + return targetedAppointment; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.test.ts new file mode 100644 index 000000000000..4f40610c55fd --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.test.ts @@ -0,0 +1,281 @@ +import { + describe, expect, it, +} from '@jest/globals'; +import type { SafeAppointment } from '@ts/scheduler/types'; + +import type { AppointmentDataSource } from '../../view_model/m_appointment_data_source'; +import type { AppointmentItemViewModel } from '../../view_model/types'; +import { getViewModelDiff } from './get_view_model_diff'; + +type ItemData = Record; + +const createMockDataSource = ( + updatedAppointment: ItemData | null = null, + updatedKeys: { key: string; value: unknown }[] = [], +): AppointmentDataSource => ({ + getUpdatedAppointment: () => updatedAppointment, + getUpdatedAppointmentKeys: () => updatedKeys, +} as unknown as AppointmentDataSource); + +const defaultDataSource = createMockDataSource(); + +const makeItem = ( + itemData: SafeAppointment, + overrides: Partial = {}, +): AppointmentItemViewModel => ({ + itemData, + allDay: false, + groupIndex: 0, + sortedIndex: 0, + direction: 'vertical', + skipResizing: false, + level: 0, + maxLevel: 0, + empty: false, + left: 0, + top: 0, + height: 100, + width: 200, + reduced: undefined, + partIndex: 0, + partTotalCount: 1, + rowIndex: 0, + columnIndex: 0, + info: { + sourceAppointment: { startDate: new Date(), endDate: new Date() }, + appointment: { startDate: new Date(), endDate: new Date() }, + }, + ...overrides, +} as AppointmentItemViewModel); + +const getOperations = (items: ReturnType): string => items + .map((item) => { + if (item.needToAdd) return '+'; + if (item.needToRemove) return '-'; + if (item.needToResize) return 'r'; + return '='; + }) + .join(''); + +describe('getViewModelDiff', () => { + it('should return empty array for both empty inputs', () => { + expect(getViewModelDiff([], [], defaultDataSource)).toEqual([]); + }); + + it('should mark no changes for identical items', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data3: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data3)]; + const b = [makeItem(data1), makeItem(data2), makeItem(data3)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('==='); + expect(diff).toEqual([{ item: b[0] }, { item: b[1] }, { item: b[2] }]); + }); + + it('should mark all as needToAdd when old list is empty', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const b = [makeItem(data1), makeItem(data2)]; + + const diff = getViewModelDiff([], b, defaultDataSource); + + expect(getOperations(diff)).toBe('++'); + expect(diff).toEqual([ + { item: b[0], needToAdd: true }, + { item: b[1], needToAdd: true }, + ]); + }); + + it('should mark all as needToRemove when new list is empty', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const a = [makeItem(data1), makeItem(data2)]; + + const diff = getViewModelDiff(a, [], defaultDataSource); + + expect(getOperations(diff)).toBe('--'); + expect(diff).toEqual([ + { item: a[0], needToRemove: true }, + { item: a[1], needToRemove: true }, + ]); + }); + + it('should mark remove and add for one item replacement (different itemData)', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data3: ItemData = {}; + const data4: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data4)]; + const b = [makeItem(data1), makeItem(data3), makeItem(data4)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('=+-='); + expect(diff).toEqual([ + { item: b[0] }, + { item: b[1], needToAdd: true }, + { item: a[1], needToRemove: true }, + { item: b[2] }, + ]); + }); + + it('should mark remove and add for same itemData with changed non-dimension properties', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data4: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data4)]; + const b = [makeItem(data1), makeItem(data2, { rowIndex: 1 }), makeItem(data4)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('=+-='); + expect(diff).toEqual([ + { item: b[0] }, + { item: b[1], needToAdd: true }, + { item: a[1], needToRemove: true }, + { item: b[2] }, + ]); + }); + + it('should choose optimum operations for reordering', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data3: ItemData = {}; + const data4: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + const b = [makeItem(data4), makeItem(data1), makeItem(data2), makeItem(data3)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('+===-'); + expect(diff).toEqual([ + { item: b[0], needToAdd: true }, + { item: b[1] }, + { item: b[2] }, + { item: b[3] }, + { item: a[3], needToRemove: true }, + ]); + }); + + it('should choose optimum operations for reordering, insertion, and removal', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data3: ItemData = {}; + const data4: ItemData = {}; + const data5: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + const b = [makeItem(data4), makeItem(data1), makeItem(data5), makeItem(data3)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('+=+-=-'); + expect(diff).toEqual([ + { item: b[0], needToAdd: true }, + { item: b[1] }, + { item: b[2], needToAdd: true }, + { item: a[1], needToRemove: true }, + { item: b[3] }, + { item: a[3], needToRemove: true }, + ]); + }); + + it('should use the new item (from new list) in no-change cases', () => { + const data1: ItemData = { myId: 0 }; + const data2: ItemData = { myId: 1 }; + const data3: ItemData = { myId: 2 }; + const data4: ItemData = { myId: 3 }; + const data5: ItemData = { myId: 4 }; + const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + // bItem1 uses the same data2 ref as a[1] but with a different sortedIndex, + // which is not part of the comparison object — items are still considered equal. + const bItem1 = makeItem(data2, { sortedIndex: 99 }); + const b = [makeItem(data4), bItem1, makeItem(data5), makeItem(data3)]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('+-=+=-'); + expect(diff[2]).toEqual({ item: bItem1 }); + }); + + describe('needToResize', () => { + it('should mark needToResize when only dimensions change for the same item', () => { + const data1: ItemData = {}; + const a = [makeItem(data1, { + left: 0, top: 0, height: 100, width: 200, + })]; + const b = [makeItem(data1, { + left: 10, top: 20, height: 50, width: 150, + })]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('r'); + expect(diff).toEqual([{ item: b[0], needToResize: true }]); + }); + + it('should mix needToResize with other operations', () => { + const data1: ItemData = {}; + const data2: ItemData = {}; + const data3: ItemData = {}; + const a = [makeItem(data1), makeItem(data2), makeItem(data3)]; + const b = [ + makeItem(data1), + makeItem(data2, { left: 50, top: 50 }), + makeItem(data3), + ]; + + const diff = getViewModelDiff(a, b, defaultDataSource); + + expect(getOperations(diff)).toBe('=r='); + expect(diff).toEqual([ + { item: b[0] }, + { item: b[1], needToResize: true }, + { item: b[2] }, + ]); + }); + }); + + describe('updatedAppointment', () => { + it('should treat item as changed when itemData matches getUpdatedAppointment reference', () => { + const data1: ItemData = {}; + const a = [makeItem(data1)]; + const b = [makeItem(data1)]; + const dataSource = createMockDataSource(data1); + + const diff = getViewModelDiff(a, b, dataSource); + + expect(getOperations(diff)).toBe('+-'); + expect(diff).toEqual([ + { item: b[0], needToAdd: true }, + { item: a[0], needToRemove: true }, + ]); + }); + + it('should treat item as changed when its data matches an updatedAppointmentKey', () => { + const data1: ItemData = { id: 1 }; + const a = [makeItem(data1)]; + const b = [makeItem(data1)]; + const dataSource = createMockDataSource(null, [{ key: 'id', value: 1 }]); + + const diff = getViewModelDiff(a, b, dataSource); + + expect(getOperations(diff)).toBe('+-'); + }); + + it('should not affect items whose data has not changed', () => { + const data1: ItemData = { id: 1 }; + const data2: ItemData = { id: 2 }; + const updatedData: ItemData = { id: 3 }; + const a = [makeItem(data1), makeItem(data2)]; + const b = [makeItem(data1), makeItem(data2)]; + const dataSource = createMockDataSource(updatedData); + + const diff = getViewModelDiff(a, b, dataSource); + + expect(getOperations(diff)).toBe('=='); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts new file mode 100644 index 000000000000..02213a5de55f --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts @@ -0,0 +1,153 @@ +import { equalByValue } from '@js/core/utils/common'; + +import type { SafeAppointment } from '../../types'; +import type { AppointmentDataSource } from '../../view_model/m_appointment_data_source'; +import type { AppointmentCollectorViewModel, AppointmentItemViewModel } from '../../view_model/types'; +import { isCollectorViewModel } from './type_helpers'; + +type Item = AppointmentItemViewModel | AppointmentCollectorViewModel; + +export interface DiffItem { + needToAdd?: boolean; + needToRemove?: boolean; + needToResize?: boolean; + item: AppointmentItemViewModel | AppointmentCollectorViewModel; +} + +const getObjectToCompare = (item: Item, includeDimensions: boolean): object => { + const result = isCollectorViewModel(item) + ? { + allDay: item.allDay, + groupIndex: item.groupIndex, + items: item.items.length, + } + : { + allDay: item.allDay, + groupIndex: item.groupIndex, + direction: item.direction, + reduced: item.reduced, + partIndex: item.partIndex, + partTotalCount: item.partTotalCount, + rowIndex: item.rowIndex, + columnIndex: item.columnIndex, + }; + + return includeDimensions + ? { + ...result, + left: item.left, + top: item.top, + height: item.height, + width: item.width, + } + : result; +}; + +const isAppointmentDataChanged = ( + appointmentData: SafeAppointment, + appointmentDataSource: AppointmentDataSource, +): boolean => { + const updatedAppointmentData = appointmentDataSource.getUpdatedAppointment(); + + if (updatedAppointmentData === appointmentData) { + return true; + } + + const updateAppointmentKeys = appointmentDataSource.getUpdatedAppointmentKeys(); + + return updateAppointmentKeys.some((item) => appointmentData[item.key] === item.value); +}; + +function getArraysDiff(options: { + a: Item[]; + b: Item[]; + match: (x: Item, y: Item) => boolean; + equal: (x: Item, y: Item) => boolean; + canResize: (x: Item, y: Item) => boolean; +}): DiffItem[] { + const { + a, b, match, equal, canResize, + } = options; + const n = a.length; + const m = b.length; + + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + + for (let i = 1; i <= n; i += 1) { + const ai = a[i - 1]; + for (let j = 1; j <= m; j += 1) { + dp[i][j] = match(ai, b[j - 1]) + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + const result: DiffItem[] = []; + let i = n; + let j = m; + + while (i > 0 && j > 0) { + const ai = a[i - 1]; + const bj = b[j - 1]; + + if (match(ai, bj)) { + if (equal(ai, bj)) { + result.push({ item: bj }); + } else if (canResize(ai, bj)) { + result.push({ item: bj, needToResize: true }); + } else { + result.push({ item: ai, needToRemove: true }); + result.push({ item: bj, needToAdd: true }); + } + + i -= 1; + j -= 1; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + result.push({ item: ai, needToRemove: true }); + i -= 1; + } else { + result.push({ item: bj, needToAdd: true }); + j -= 1; + } + } + + while (i > 0) { + result.push({ item: a[i - 1], needToRemove: true }); + i -= 1; + } + while (j > 0) { + result.push({ item: b[j - 1], needToAdd: true }); + j -= 1; + } + + result.reverse(); + return result; +} + +export const getViewModelDiff = ( + oldViewModel: Item[], + newViewModel: Item[], + appointmentDataSource: AppointmentDataSource, +): DiffItem[] => { + const match = (a: Item, b: Item): boolean => + // eslint-disable-next-line @stylistic/implicit-arrow-linebreak + a.itemData === b.itemData && !isAppointmentDataChanged(b.itemData, appointmentDataSource); + + const equal = (a: Item, b: Item): boolean => + // eslint-disable-next-line @stylistic/implicit-arrow-linebreak + equalByValue(getObjectToCompare(a, true), getObjectToCompare(b, true)); + + const canResize = (a: Item, b: Item): boolean => + // eslint-disable-next-line @stylistic/implicit-arrow-linebreak + equalByValue(getObjectToCompare(a, false), getObjectToCompare(b, false)); + + const result = getArraysDiff({ + a: oldViewModel, + b: newViewModel, + match, + equal, + canResize, + }); + + return result; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/type_helpers.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/type_helpers.ts new file mode 100644 index 000000000000..ec4f570e84e5 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/type_helpers.ts @@ -0,0 +1,18 @@ +import type { + AppointmentAgendaViewModel, + AppointmentCollectorViewModel, + AppointmentItemViewModel, + AppointmentViewModelPlain, +} from '../../view_model/types'; + +export const isAgendaAppointmentViewModel = (appointmentViewModel: AppointmentViewModelPlain): + appointmentViewModel is AppointmentAgendaViewModel => 'isAgendaModel' in appointmentViewModel; + +export const isCollectorViewModel = (appointmentViewModel: AppointmentViewModelPlain): + appointmentViewModel is AppointmentCollectorViewModel => 'isCompact' in appointmentViewModel; + +export const isGridAppointmentViewModel = (appointmentViewModel: AppointmentViewModelPlain): + appointmentViewModel is AppointmentItemViewModel => + // eslint-disable-next-line @stylistic/implicit-arrow-linebreak + !isAgendaAppointmentViewModel(appointmentViewModel) + && !isCollectorViewModel(appointmentViewModel); diff --git a/packages/devextreme/js/__internal/scheduler/m_classes.ts b/packages/devextreme/js/__internal/scheduler/m_classes.ts index bc75e49b2287..3e06d8bc1334 100644 --- a/packages/devextreme/js/__internal/scheduler/m_classes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_classes.ts @@ -1,3 +1,4 @@ +// TODO: delete unused classes when old impl is removed export const FIXED_CONTAINER_CLASS = 'dx-scheduler-fixed-appointments'; export const REDUCED_APPOINTMENT_CLASS = 'dx-scheduler-appointment-reduced'; export const REDUCED_APPOINTMENT_ICON = 'dx-scheduler-appointment-reduced-icon'; diff --git a/packages/devextreme/js/__internal/scheduler/m_compact_appointments_helper.ts b/packages/devextreme/js/__internal/scheduler/m_compact_appointments_helper.ts index 4214cf6750ec..4664d3cf98e3 100644 --- a/packages/devextreme/js/__internal/scheduler/m_compact_appointments_helper.ts +++ b/packages/devextreme/js/__internal/scheduler/m_compact_appointments_helper.ts @@ -14,6 +14,7 @@ const APPOINTMENT_COLLECTOR_CLASS = 'dx-scheduler-appointment-collector'; const COMPACT_APPOINTMENT_COLLECTOR_CLASS = `${APPOINTMENT_COLLECTOR_CLASS}-compact`; const APPOINTMENT_COLLECTOR_CONTENT_CLASS = `${APPOINTMENT_COLLECTOR_CLASS}-content`; +// TODO: delete this file when old impl is removed export class CompactAppointmentsHelper { elements: any[] = []; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index de3f4a0fe049..53a67c9b09c6 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -30,6 +30,7 @@ import DataHelperMixin from '@js/data_helper'; import { custom as customDialog } from '@js/ui/dialog'; import type { Appointment, AppointmentTooltipShowingEvent, FirstDayOfWeek, Occurrence, + Properties as SchedulerProperties, } from '@js/ui/scheduler'; import errors from '@js/ui/widget/ui.errors'; import { dateUtilsTs } from '@ts/core/utils/date'; @@ -41,6 +42,8 @@ import { AppointmentForm as AppointmentLegacyForm } from './appointment_popup/m_ import { ACTION_TO_APPOINTMENT, AppointmentPopup as AppointmentLegacyPopup } from './appointment_popup/m_legacy_popup'; import { AppointmentPopup } from './appointment_popup/m_popup'; import AppointmentCollection from './appointments/m_appointment_collection'; +import type { AppointmentsProperties } from './appointments_new/appointments'; +import { Appointments } from './appointments_new/appointments'; import NotifyScheduler from './base/m_widget_notify_scheduler'; import { SchedulerHeader } from './header/m_header'; import type { HeaderOptions } from './header/types'; @@ -67,6 +70,7 @@ import type { AppointmentTooltipItem, SafeAppointment, ScrollToGroupValuesOrOptions, ScrollToOptions, TargetedAppointment, + ViewType, } from './types'; import { AppointmentAdapter } from './utils/appointment_adapter/appointment_adapter'; import { AppointmentDataAccessor } from './utils/data_accessor/appointment_data_accessor'; @@ -225,6 +229,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { private timeZonesPromise!: Promise; + private appointmentRenderedAction!: SchedulerProperties['onAppointmentRendered']; + get timeZoneCalculator() { if (!this._timeZoneCalculator) { this._timeZoneCalculator = createTimeZoneCalculator(this.option('timeZone')); @@ -310,18 +316,30 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.updateOption('header', name, value); break; case 'currentView': - this._appointments.option({ - items: [], - allowDrag: this.allowDragging(), - allowResize: this.allowResizing(), - itemTemplate: this.getAppointmentTemplate('appointmentTemplate'), - }); + + if (this.option('_newAppointments')) { + this._appointments.option({ + currentView: value, + viewModel: [], + // TODO: update appointmentTemplate and appointmentCollectorTemplate + }); + } else { + this._appointments.option({ + items: [], + allowDrag: this.allowDragging(), + allowResize: this.allowResizing(), + itemTemplate: this.getAppointmentTemplate('appointmentTemplate'), + }); + } this.postponeResourceLoading().done(() => { this.refreshWorkSpace(); this.header?.option(this.headerConfig()); this.setRemoteFilterIfNeeded(); - this._appointments.option('allowAllDayResize', value !== 'day'); + + if (!this.option('_newAppointments')) { + this._appointments.option('allowAllDayResize', value !== 'day'); + } }); // NOTE: // Calling postponed operations (promises) here, because when we update options with @@ -362,7 +380,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._appointments.option('items', []); this.updateOption('workSpace', name, value); - this._appointments.repaint(); + if (!this.option('_newAppointments')) { + // TODO: no need to call repaint on new appointments + this._appointments.repaint(); + } this.setRemoteFilterIfNeeded(); this.postponeDataSourceLoading(); @@ -374,7 +395,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._appointments.option('items', []); this.updateOption('workSpace', 'viewOffset', this.normalizeViewOffsetValue(value)); - this._appointments.repaint(); + if (!this.option('_newAppointments')) { + // TODO: no need to call repaint on new appointments + this._appointments.repaint(); + } this.setRemoteFilterIfNeeded(); this.postponeDataSourceLoading(); @@ -390,7 +414,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.actions[name] = this._createActionByOption(name); break; case 'onAppointmentRendered': - this._appointments.option('onItemRendered', this.getAppointmentRenderedAction()); + if (this.option('_newAppointments')) { + this.createAppointmentRenderedAction(); + } else { + this._appointments.option('onItemRendered', this.getAppointmentRenderedAction()); + } break; case 'onAppointmentClick': this._appointments.option('onItemClick', this._createActionByOption(name)); @@ -761,6 +789,12 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.resourceManager = new ResourceManager(this.option('resources')); this.notifyScheduler = new NotifyScheduler({ scheduler: this }); + + this.createAppointmentRenderedAction(); + } + + private createAppointmentRenderedAction() { + this.appointmentRenderedAction = this._createActionByOption('onAppointmentRendered'); } createAppointmentDataSource() { @@ -794,6 +828,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { super._initTemplates(); } + // TODO: delete this method when old impl is removed private initAppointmentTemplate() { const { expr } = this._dataAccessors; const createGetter = (property) => compileGetter(`appointmentData.${property}`); @@ -952,6 +987,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { }; } + // TODO: delete this method when old impl is removed private getAppointmentRenderedAction() { return this._createActionByOption('onAppointmentRendered', { excludeValidators: ['disabled', 'readOnly'], @@ -1002,9 +1038,36 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._layoutManager = new AppointmentLayoutManager(this); - // @ts-expect-error - this._appointments = this._createComponent('
', AppointmentCollection, this.appointmentsConfig()); - this._appointments.option('itemTemplate', this.getAppointmentTemplate('appointmentTemplate')); + if (this.option('_newAppointments')) { + // TODO: convert 'item' to 'appointment' for compatibility + const appointmentTemplateValue = this.getViewOption('appointmentTemplate') === 'item' + ? 'appointment' + : this.getViewOption('appointmentTemplate'); + + const appointmentsConfig: Partial = { + currentView: this.option('currentView') as ViewType, + // TODO: set custom templates + appointmentTemplate: appointmentTemplateValue, + appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), + onAppointmentRendered: (e) => { + // @ts-expect-error 'component' property is set by action + this.appointmentRenderedAction({ + appointmentElement: e.element, + appointmentData: e.appointmentData, + targetedAppointmentData: e.targetedAppointmentData, + }); + }, + getResourceManager: () => this.resourceManager, + getAppointmentDataSource: () => this.appointmentDataSource, + getDataAccessor: () => this._dataAccessors, + }; + // @ts-expect-error + this._appointments = this._createComponent('
', Appointments, appointmentsConfig); + } else { + // @ts-expect-error + this._appointments = this._createComponent('
', AppointmentCollection, this.appointmentsConfig()); + this._appointments.option('itemTemplate', this.getAppointmentTemplate('appointmentTemplate')); + } this.appointmentTooltip = new (this.option('adaptivityEnabled') ? MobileTooltipStrategy @@ -1172,10 +1235,14 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._workSpace && this.cleanWorkspace(); this.renderWorkSpace(); - this._appointments.option({ - fixedContainer: this._workSpace.getFixedContainer(), - allDayContainer: this._workSpace.getAllDayContainer(), - }); + if (this.option('_newAppointments')) { + this._appointments.option('$allDayContainer', this._workSpace.getAllDayContainer()); + } else { + this._appointments.option({ + fixedContainer: this._workSpace.getFixedContainer(), + allDayContainer: this._workSpace.getAllDayContainer(), + }); + } this.waitAsyncTemplate(() => this.workSpaceRecalculation?.resolve()); this.createAppointmentDataSource(); @@ -1440,10 +1507,15 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.renderWorkSpace(); if (this.readyToRenderAppointments) { - this._appointments.option({ - fixedContainer: this._workSpace.getFixedContainer(), - allDayContainer: this._workSpace.getAllDayContainer(), - }); + if (this.option('_newAppointments')) { + this._appointments.option('$allDayContainer', this._workSpace.getAllDayContainer()); + } else { + this._appointments.option({ + fixedContainer: this._workSpace.getFixedContainer(), + allDayContainer: this._workSpace.getAllDayContainer(), + }); + } + this.waitAsyncTemplate(() => this.workSpaceRecalculation.resolve()); } } @@ -1695,6 +1767,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { return rawResult; } + // TODO: delete this method when old impl is removed getTargetedAppointment(appointment: SafeAppointment, element: dxElementWrapper): TargetedAppointment { const settings = utils.dataAccessors.getAppointmentSettings(element)!; return getTargetedAppointment( diff --git a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts index ceb9b6ae1979..a4b0af7cb982 100644 --- a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts @@ -4,8 +4,8 @@ import $ from '@js/core/renderer'; import dateUtils from '@js/core/utils/date'; import { extend } from '@js/core/utils/extend'; -import { formatDates, getFormatType } from './appointments/m_text_utils'; import { getDeltaTime } from './appointments/resizing/get_delta_time'; +import { getDateFormatType, getDateText } from './appointments_new/utils/get_date_text'; import { VERTICAL_VIEW_TYPES } from './constants'; import type Scheduler from './m_scheduler'; import { utils } from './m_utils'; @@ -117,24 +117,26 @@ const subscribes = { this.hideAppointmentTooltip(); }, + // TODO: delete this method when old impl is removed createFormattedDateText( appointment: AppointmentTooltipItem['appointment'], - targetedAppointmentRaw: AppointmentTooltipItem['targetedAppointment'], + targetedAppointmentRaw: TargetedAppointment, format?: string, ) { const targetedAppointment = { ...appointment, ...targetedAppointmentRaw, } as TargetedAppointment; + const adapter = new AppointmentAdapter(targetedAppointment, this._dataAccessors); // pull out time zone converting from appointment adapter for knockout (T947938) const startDate = targetedAppointment.displayStartDate || this.timeZoneCalculator.createDate(adapter.startDate, 'toGrid'); const endDate = targetedAppointment.displayEndDate || this.timeZoneCalculator.createDate(adapter.endDate, 'toGrid'); - const formatType = format ?? getFormatType(startDate, endDate, adapter.allDay, this.currentView.type !== 'month'); + const formatType = format ?? getDateFormatType(startDate, endDate, adapter.allDay, this.currentView.type); return { text: adapter.text || messageLocalization.format('dxScheduler-noSubject'), - formatDate: formatDates(startDate, endDate, formatType), + formatDate: getDateText(startDate, endDate, formatType as any), }; }, @@ -220,10 +222,12 @@ const subscribes = { return updatedEndDate; }, + // TODO: delete this method when old impl is removed renderCompactAppointments(options: CompactAppointmentOptions): dxElementWrapper { return this._compactAppointmentsHelper.render(options); }, + // TODO: delete this method when old impl is removed clearCompactAppointments() { this._compactAppointmentsHelper.clear(); }, @@ -232,6 +236,7 @@ const subscribes = { return this._workSpace._getGroupCount(); }, + // TODO: delete this method when old impl is removed mapAppointmentFields(config) { const { itemData, itemElement, targetedAppointment } = config; const targetedData = targetedAppointment || this.getTargetedAppointment(itemData, itemElement); @@ -271,6 +276,7 @@ const subscribes = { return this.forceMaxAppointmentPerCell(); }, + // TODO: delete this method when old impl is removed getTargetedAppointmentData(appointment, element) { return this.getTargetedAppointment(appointment, element); }, diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 25f85f1d6836..2b4df4b92ec7 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -13,11 +13,9 @@ export type RenderStrategyName = 'horizontal' | 'horizontalMonth' | 'horizontalM export type FilterItemType = Record | string | number; export type HeaderCellTextFormat = string | ((date: Date) => string); -export interface SafeAppointment extends Appointment { - startDate: Date | string; - endDate: Date | string; -} -export interface TargetedAppointment extends SafeAppointment { +export interface SafeAppointment extends Appointment {} + +export interface TargetedAppointment extends Appointment { displayStartDate: Date; displayEndDate: Date; } diff --git a/packages/devextreme/js/__internal/scheduler/utils/get_targeted_appointment.ts b/packages/devextreme/js/__internal/scheduler/utils/get_targeted_appointment.ts index 40c86437608e..5df7c6eeaf29 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/get_targeted_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/get_targeted_appointment.ts @@ -1,3 +1,4 @@ +// TODO: remove this file after old impl is deleted import type { SafeAppointment, TargetedAppointment } from '../types'; import type { AppointmentAgendaViewModel,