From 9326cf9293c6741632e6d97f3be506e821f902f0 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 19 May 2026 17:43:00 +0400 Subject: [PATCH 1/8] feat(scheduler): add Delete key support for appointments_new KBN --- .../appointments.focus_controller.ts | 11 ++++ .../appointments_new/appointments.test.ts | 51 +++++++++++++++++++ .../appointments_new/appointments.ts | 7 +++ 3 files changed, 69 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index fafa90451a22..893850566e03 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -52,6 +52,8 @@ export class AppointmentsFocusController { public onViewItemKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { if (e.key === 'Tab') { this.handleTabKeyDown(e, viewItem.option().sortedIndex); + } else if (e.key === 'Delete') { + this.handleDeleteKeyDown(viewItem.option().sortedIndex); } } @@ -92,6 +94,15 @@ export class AppointmentsFocusController { this.focusByItemData(nextItemData); } + private handleDeleteKeyDown(sortedIndex: number): void { + const { allowDelete, onDeleteKeyPress } = this.appointments.option(); + if (!allowDelete) { return; } + + const itemData = this.sortedAppointments.find((s) => s.sortedIndex === sortedIndex)?.itemData; + if (!itemData) { return; } + onDeleteKeyPress({ data: itemData, target: null }); + } + private focusByItemData(itemData: SortedEntity): void { if (this.isVirtualScrolling) { this.scrollToItem(itemData); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index a188e935e645..bca639d012f7 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -55,6 +55,9 @@ const getProperties = (options: { showTooltipForAppointment: (): void => {}, showTooltipForCollector: (): void => {}, showEditAppointmentPopup: (): void => {}, + allowDelete: false, + onDeleteKeyPress: (): void => {}, + onItemActivate: (): void => {}, }); const createAppointments = ( @@ -891,6 +894,54 @@ describe('Appointments', () => { expect(lastViewItem?.$element().attr('tabindex')).toBe('0'); }); }); + + describe('Keyboard actions', () => { + it('should call onDeleteKeyPress when Delete is pressed and allowDelete is true', () => { + const onDeleteKeyPress = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + allowDelete: true, + onDeleteKeyPress, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Delete' }); + + expect(onDeleteKeyPress).toHaveBeenCalledTimes(1); + expect(onDeleteKeyPress).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); + + it('should not call onDeleteKeyPress when Delete is pressed and allowDelete is false', () => { + const onDeleteKeyPress = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + allowDelete: false, + onDeleteKeyPress, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Delete' }); + + expect(onDeleteKeyPress).not.toHaveBeenCalled(); + }); + }); }); describe('onAppointmentRendered', () => { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index f9f2ed3dd111..71e57ee57eb3 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -82,6 +82,10 @@ export interface AppointmentsProperties extends DOMComponentProperties void; + + allowDelete: boolean; + onDeleteKeyPress: (options: { data: SafeAppointment; target: EventTarget | null }) => void; + onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; } export class Appointments extends DOMComponent { @@ -152,6 +156,9 @@ export class Appointments extends DOMComponent {}, + onItemActivate: (): void => {}, }; } From b2a0571affb3dc1d9f15811e57dcbd2492b0467b Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 19 May 2026 23:59:00 +0400 Subject: [PATCH 2/8] feat(scheduler): add KBN home/end/enter/space + fix delete occurrence --- .../__tests__/appointments_new.test.ts | 39 +++++++ .../appointments.focus_controller.ts | 53 +++++++-- .../appointments_new/appointments.test.ts | 108 ++++++++++++++++-- .../appointments_new/appointments.ts | 3 +- .../js/__internal/scheduler/m_scheduler.ts | 5 + 5 files changed, 192 insertions(+), 16 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index ba81f77f6b1c..a92c23a72c69 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -4,6 +4,7 @@ import { import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { Properties } from '@js/ui/scheduler'; +import { fireEvent } from '@testing-library/dom'; import { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; @@ -756,4 +757,42 @@ describe('New Appointments', () => { expect(appointment.text).toBe('Updated Appointment'); }); }); + + describe('Keyboard navigation', () => { + it('should delete appointment by delete key', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(0); + }); + + it('should delete recurring appointment occurrence by delete key', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=3', + }], + currentDate: new Date(2015, 1, 9), + recurrenceEditMode: 'occurrence', + }); + + expect(POM.getAppointments().length).toBe(3); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(2); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 893850566e03..27f92840f82f 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -50,10 +50,27 @@ export class AppointmentsFocusController { } public onViewItemKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { - if (e.key === 'Tab') { - this.handleTabKeyDown(e, viewItem.option().sortedIndex); - } else if (e.key === 'Delete') { - this.handleDeleteKeyDown(viewItem.option().sortedIndex); + switch (true) { + case e.key === 'Tab': + this.handleTabKeyDown(e, viewItem.option().sortedIndex); + break; + case e.key === 'Delete': + this.handleDeleteKeyDown(viewItem.option().sortedIndex); + break; + case e.key === 'Home': + this.handleHomeKeyDown(); + break; + case e.key === 'End': + this.handleEndKeyDown(); + break; + case e.key === 'Enter': + this.handleEnterKeyDown(viewItem.option().sortedIndex); + break; + case e.key === ' ': + this.handleEnterKeyDown(viewItem.option().sortedIndex); + break; + default: + break; } } @@ -95,12 +112,32 @@ export class AppointmentsFocusController { } private handleDeleteKeyDown(sortedIndex: number): void { - const { allowDelete, onDeleteKeyPress } = this.appointments.option(); + const { allowDelete, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); if (!allowDelete) { return; } - const itemData = this.sortedAppointments.find((s) => s.sortedIndex === sortedIndex)?.itemData; - if (!itemData) { return; } - onDeleteKeyPress({ data: itemData, target: null }); + const entity = this.sortedAppointments[sortedIndex]; + if (!entity) { return; } + + const occurrence = { ...entity.itemData }; + getDataAccessor().set('startDate', occurrence, new Date(entity.source.startDate)); + + onDeleteKeyPress({ appointment: entity.itemData, occurrence }); + } + + private handleHomeKeyDown(): void { + const firstAppointment = this.sortedAppointments[0]; + if (firstAppointment) { this.focusByItemData(firstAppointment); } + } + + private handleEndKeyDown(): void { + const lastAppointment = this.sortedAppointments[this.sortedAppointments.length - 1]; + if (lastAppointment) { this.focusByItemData(lastAppointment); } + } + + private handleEnterKeyDown(sortedIndex: number): void { + const { onItemActivate } = this.appointments.option(); + const entity = this.sortedAppointments[sortedIndex]; + onItemActivate({ data: entity?.itemData, target: null }); } private focusByItemData(itemData: SortedEntity): void { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index bca639d012f7..f2ad8f35c378 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -4,7 +4,6 @@ import { import $ from '@js/core/renderer'; import { fireEvent } from '@testing-library/dom'; -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'; @@ -83,8 +82,6 @@ const dblClick = (element: HTMLElement): void => { describe('Appointments', () => { beforeEach(() => { - fx.off = true; - const $container = $('
') .addClass('container') .appendTo(document.body); @@ -100,8 +97,6 @@ describe('Appointments', () => { afterEach(() => { $('.container').remove(); - fx.off = false; - jest.useRealTimers(); }); describe('Classes', () => { @@ -895,6 +890,56 @@ describe('Appointments', () => { }); }); + describe('Home/End navigation', () => { + it('should move focus to first appointment on Home key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem2 = instance.getViewItemBySortedIndex(2); + + (viewItem2?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem2?.$element().get(0) as HTMLElement, { key: 'Home' }); + + expect(viewItem0?.$element().attr('tabindex')).toBe('0'); + expect(viewItem2?.$element().attr('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(viewItem0?.$element().get(0)); + }); + + it('should move focus to last appointment on End key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem2 = instance.getViewItemBySortedIndex(2); + + (viewItem0?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'End' }); + + expect(viewItem2?.$element().attr('tabindex')).toBe('0'); + expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(viewItem2?.$element().get(0)); + }); + }); + describe('Keyboard actions', () => { it('should call onDeleteKeyPress when Delete is pressed and allowDelete is true', () => { const onDeleteKeyPress = jest.fn(); @@ -907,7 +952,11 @@ describe('Appointments', () => { ...getProperties(), allowDelete: true, onDeleteKeyPress, - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedAppointments: () => [{ + sortedIndex: 0, + itemData: defaultAppointmentData, + source: { startDate: 0 }, + }] as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -917,7 +966,7 @@ describe('Appointments', () => { expect(onDeleteKeyPress).toHaveBeenCalledTimes(1); expect(onDeleteKeyPress).toHaveBeenCalledWith( - expect.objectContaining({ data: defaultAppointmentData }), + expect.objectContaining({ appointment: defaultAppointmentData }), ); }); @@ -941,6 +990,51 @@ describe('Appointments', () => { expect(onDeleteKeyPress).not.toHaveBeenCalled(); }); + + it('should call onItemActivate when Enter is pressed', () => { + const onItemActivate = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + onItemActivate, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); + + expect(onItemActivate).toHaveBeenCalledTimes(1); + expect(onItemActivate).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); + it('should call onItemActivate when Space is pressed', () => { + const onItemActivate = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + onItemActivate, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); + + expect(onItemActivate).toHaveBeenCalledTimes(1); + expect(onItemActivate).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 71e57ee57eb3..5e70e0c5d234 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -84,7 +84,8 @@ export interface AppointmentsProperties extends DOMComponentProperties void; allowDelete: boolean; - onDeleteKeyPress: (options: { data: SafeAppointment; target: EventTarget | null }) => void; + onDeleteKeyPress: (options: + { appointment: SafeAppointment; occurrence: SafeAppointment }) => void; onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index d1970d0578b8..7f1057e69fb8 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1062,12 +1062,17 @@ class Scheduler extends SchedulerOptionsBaseWidget { const appointmentsConfig: Partial = { tabIndex: this.option('tabIndex'), currentView: this.option('currentView') as ViewType, + allowDelete: this.editing.allowUpdating && this.editing.allowDeleting, appointmentTemplate: this.getViewOption('appointmentTemplate'), appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), onAppointmentRendered: (...args) => this.actions.onAppointmentRendered(...args), onAppointmentClick: (...args) => this.actions.onAppointmentClick(...args), onAppointmentDblClick: (...args) => this.actions.onAppointmentDblClick(...args), + onDeleteKeyPress: (e) => { + this.checkAndDeleteAppointment(e.appointment, e.occurrence); + }, + onItemActivate: ({ data }) => { this.showAppointmentPopup(data); }, getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, From d6b1408ee514a93cd84705aae0580d9ee89c04e5 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 20 May 2026 12:26:29 +0400 Subject: [PATCH 3/8] refactor(scheduler): replace dispatchEvent with fireEvent in appointments tests --- .../appointments_new/appointments.test.ts | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index f2ad8f35c378..81cfc9ebc300 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -559,9 +559,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); expect(viewItem1?.$element().attr('tabindex')).toBe('0'); @@ -585,9 +583,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab', shiftKey: true }); expect(viewItem0?.$element().attr('tabindex')).toBe('0'); expect(viewItem1?.$element().attr('tabindex')).toBe('-1'); @@ -659,9 +655,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(0); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalled(); }); @@ -684,9 +678,7 @@ describe('Appointments', () => { const viewItem2 = instance.getViewItemBySortedIndex(2); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(document.activeElement).toBe(viewItem2?.$element().get(0)); }); @@ -709,9 +701,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); // item2 is not rendered yet, so focus cannot move yet expect(instance.getViewItemBySortedIndex(2)).toBeUndefined(); @@ -745,9 +735,7 @@ describe('Appointments', () => { const viewItem0 = instance.getViewItemBySortedIndex(0); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalledWith(appointmentStartDate, expect.anything()); }); @@ -771,9 +759,7 @@ describe('Appointments', () => { const viewItem0 = instance.getViewItemBySortedIndex(0); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalledWith(startViewDate, expect.anything()); }); @@ -782,9 +768,7 @@ describe('Appointments', () => { describe('Navigation after partial render', () => { const pressTab = (): void => { const activeElement = document.activeElement as HTMLElement; - activeElement.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(activeElement, { key: 'Tab' }); }; it('should navigate to the last appointment correctly after an appointment is added', () => { From b6f5f24ee04ad0aa20dd3881c7374aa3653cf285 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 20 May 2026 12:53:45 +0400 Subject: [PATCH 4/8] fix(scheduler): use week view in recurring delete integration test --- .../js/__internal/scheduler/__tests__/appointments_new.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index a92c23a72c69..1e76e569f8e8 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -783,6 +783,7 @@ describe('New Appointments', () => { recurrenceRule: 'FREQ=DAILY;COUNT=3', }], currentDate: new Date(2015, 1, 9), + currentView: 'week', recurrenceEditMode: 'occurrence', }); From fe04654386a7571343fe4f258495a84a614222d6 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Fri, 22 May 2026 02:00:38 +0400 Subject: [PATCH 5/8] refactor(scheduler): address KBN review feedback for appointments_new --- .../__tests__/appointments_new.test.ts | 45 ++++++++ .../appointment/base_appointment.ts | 4 +- .../appointments.focus_controller.ts | 81 ++++++++------ .../appointments_new/appointments.test.ts | 103 +++++++++++++++--- .../appointments_new/appointments.ts | 9 +- .../js/__internal/scheduler/m_scheduler.ts | 18 ++- 6 files changed, 199 insertions(+), 61 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index 1e76e569f8e8..7ebc570c4518 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -771,6 +771,7 @@ describe('New Appointments', () => { const appointment = POM.getAppointments()[0]; appointment.element.focus(); fireEvent.keyDown(appointment.element, { key: 'Delete' }); + await new Promise(process.nextTick); expect(POM.getAppointments().length).toBe(0); }); @@ -792,8 +793,52 @@ describe('New Appointments', () => { const appointment = POM.getAppointments()[0]; appointment.element.focus(); fireEvent.keyDown(appointment.element, { key: 'Delete' }); + await new Promise(process.nextTick); expect(POM.getAppointments().length).toBe(2); }); + + it.each([ + { editing: true }, + { editing: { allowDeleting: true } }, + { editing: { allowDeleting: true, allowUpdating: false } }, + ])('should delete appointment when editing=$editing', async ({ editing }) => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + editing, + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(0); + }); + + it.each([ + { editing: { allowDeleting: false } }, + { editing: false }, + ])('should NOT delete appointment when editing=$editing', async ({ editing }) => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + editing, + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(1); + }); }); }); 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 index 782f8a87be0a..50583424a003 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts @@ -38,11 +38,11 @@ export interface BaseAppointmentViewProperties export class BaseAppointmentView< TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties, > extends ViewItem { - get targetedAppointmentData(): TargetedAppointment { + public get targetedAppointmentData(): TargetedAppointment { return this.option().targetedAppointmentData; } - get appointmentData(): SafeAppointment { + public get appointmentData(): SafeAppointment { return this.option().appointmentData; } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 27f92840f82f..0388b4a13e71 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -5,6 +5,8 @@ import { focus } from '@ts/events/m_short'; import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; import type { SortedEntity } from '../view_model/types'; +import type { BaseAppointmentView } from './appointment/base_appointment'; +import { AppointmentCollector } from './appointment_collector'; import type { Appointments } from './appointments'; import type { ViewItem } from './view_item'; @@ -14,7 +16,7 @@ export class AppointmentsFocusController { private needRestoreFocusIndex = -1; private get sortedAppointments(): SortedEntity[] { - return this.appointments.option().getSortedAppointments(); + return this.appointments.option().getSortedItems(); } private get isVirtualScrolling(): boolean { @@ -55,19 +57,19 @@ export class AppointmentsFocusController { this.handleTabKeyDown(e, viewItem.option().sortedIndex); break; case e.key === 'Delete': - this.handleDeleteKeyDown(viewItem.option().sortedIndex); + this.handleDeleteKeyDown(viewItem); break; case e.key === 'Home': - this.handleHomeKeyDown(); + this.handleHomeKeyDown(e); break; case e.key === 'End': - this.handleEndKeyDown(); + this.handleEndKeyDown(e); break; case e.key === 'Enter': - this.handleEnterKeyDown(viewItem.option().sortedIndex); + this.handleEnterKeyDown(viewItem, e); break; case e.key === ' ': - this.handleEnterKeyDown(viewItem.option().sortedIndex); + this.handleEnterKeyDown(viewItem, e); break; default: break; @@ -108,49 +110,64 @@ export class AppointmentsFocusController { } e.originalEvent.preventDefault(); - this.focusByItemData(nextItemData); + this.focusBySortedItem(nextItemData); } - private handleDeleteKeyDown(sortedIndex: number): void { - const { allowDelete, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); - if (!allowDelete) { return; } + private handleDeleteKeyDown(viewItem: ViewItem): void { + if (viewItem instanceof AppointmentCollector) { return; } - const entity = this.sortedAppointments[sortedIndex]; - if (!entity) { return; } + const { allowDelete, onDeleteKeyPress } = this.appointments.option(); + if (!allowDelete) { return; } - const occurrence = { ...entity.itemData }; - getDataAccessor().set('startDate', occurrence, new Date(entity.source.startDate)); + const sortedItem = this.sortedAppointments[viewItem.option().sortedIndex]; + if (!sortedItem) { return; } - onDeleteKeyPress({ appointment: entity.itemData, occurrence }); + const appointmentViewItem = viewItem as BaseAppointmentView; + onDeleteKeyPress({ + appointmentData: sortedItem.itemData, + targetedAppointmentData: appointmentViewItem.targetedAppointmentData, + }); } - private handleHomeKeyDown(): void { - const firstAppointment = this.sortedAppointments[0]; - if (firstAppointment) { this.focusByItemData(firstAppointment); } + private handleHomeKeyDown(e: KeyboardKeyDownEvent): void { + const firstSortedItem = this.sortedAppointments[0]; + if (firstSortedItem) { + e.originalEvent.preventDefault(); + this.focusBySortedItem(firstSortedItem); + } } - private handleEndKeyDown(): void { - const lastAppointment = this.sortedAppointments[this.sortedAppointments.length - 1]; - if (lastAppointment) { this.focusByItemData(lastAppointment); } + private handleEndKeyDown(e: KeyboardKeyDownEvent): void { + const lastSortedItem = this.sortedAppointments[this.sortedAppointments.length - 1]; + if (lastSortedItem) { + e.originalEvent.preventDefault(); + this.focusBySortedItem(lastSortedItem); + } } - private handleEnterKeyDown(sortedIndex: number): void { + private handleEnterKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { const { onItemActivate } = this.appointments.option(); - const entity = this.sortedAppointments[sortedIndex]; - onItemActivate({ data: entity?.itemData, target: null }); + const sortedItem = this.sortedAppointments[viewItem.option().sortedIndex]; + if (!sortedItem) { return; } + e.originalEvent.preventDefault(); + const appointmentViewItem = viewItem as BaseAppointmentView; + onItemActivate({ + data: sortedItem.itemData, + targetedAppointmentData: appointmentViewItem.targetedAppointmentData, + }); } - private focusByItemData(itemData: SortedEntity): void { + private focusBySortedItem(sortedItem: SortedEntity): void { if (this.isVirtualScrolling) { - this.scrollToItem(itemData); + this.scrollToItem(sortedItem); } - const viewItem = this.appointments.getViewItemBySortedIndex(itemData.sortedIndex); + const viewItem = this.appointments.getViewItemBySortedIndex(sortedItem.sortedIndex); if (viewItem) { this.focusViewItem(viewItem); } else if (this.isVirtualScrolling) { - this.needRestoreFocusIndex = itemData.sortedIndex; + this.needRestoreFocusIndex = sortedItem.sortedIndex; } } @@ -159,19 +176,19 @@ export class AppointmentsFocusController { focus.trigger(viewItem?.$element()); } - private scrollToItem(itemData: SortedEntity): void { + private scrollToItem(sortedItem: SortedEntity): void { const { getStartViewDate, getResourceManager, scrollTo } = this.appointments.option(); const date = new Date(Math.max( getStartViewDate().getTime(), - itemData.source.startDate, + sortedItem.source.startDate, )); const group = getRawAppointmentGroupValues( - itemData.itemData, + sortedItem.itemData, getResourceManager().resources, ); - scrollTo(date, { group, allDay: itemData.allDay }); + scrollTo(date, { group, allDay: sortedItem.allDay }); } } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 81cfc9ebc300..4f59fc5e45c8 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -43,7 +43,7 @@ const getProperties = (options: { onAppointmentDblClick: (): void => {}, getStartViewDate: () => new Date(2024, 0, 1), - getSortedAppointments: () => [], + getSortedItems: () => [], isVirtualScrolling: () => false, scrollTo: (): void => {}, @@ -551,7 +551,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -575,7 +575,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -644,7 +644,7 @@ describe('Appointments', () => { ...getProperties(), isVirtualScrolling: () => true, scrollTo, - getSortedAppointments: () => [ + getSortedItems: () => [ makeSortedEntity(0), makeSortedEntity(1), makeSortedEntity(1), ], }); @@ -664,7 +664,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), isVirtualScrolling: () => true, - getSortedAppointments: () => [ + getSortedItems: () => [ makeSortedEntity(0), makeSortedEntity(1), makeSortedEntity(2), ], }); @@ -691,7 +691,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), isVirtualScrolling: () => true, - getSortedAppointments: () => [ + getSortedItems: () => [ makeSortedEntity(0), makeSortedEntity(1), makeSortedEntity(2), ], }); @@ -725,7 +725,7 @@ describe('Appointments', () => { isVirtualScrolling: () => true, scrollTo, getStartViewDate: () => startViewDate, - getSortedAppointments: () => [ + getSortedItems: () => [ makeSortedEntity(0), makeSortedEntity(1, appointmentStartDate), ], }); @@ -753,7 +753,7 @@ describe('Appointments', () => { isVirtualScrolling: () => true, scrollTo, getStartViewDate: () => startViewDate, - getSortedAppointments: () => sortedEntities, + getSortedItems: () => sortedEntities, }); instance.option('viewModel', viewModel); @@ -784,7 +784,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -820,7 +820,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -852,7 +852,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -884,7 +884,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -899,6 +899,52 @@ describe('Appointments', () => { expect(document.activeElement).toBe(viewItem0?.$element().get(0)); }); + it('should prevent default browser behavior on Home key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedItems: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem1 = instance.getViewItemBySortedIndex(1); + (viewItem1?.$element().get(0) as HTMLElement).click(); + + const wasDefaultPrevented = !fireEvent.keyDown( + viewItem1?.$element().get(0) as HTMLElement, + { key: 'Home' }, + ); + + expect(wasDefaultPrevented).toBe(true); + }); + + it('should prevent default browser behavior on End key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedItems: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + (viewItem0?.$element().get(0) as HTMLElement).click(); + + const wasDefaultPrevented = !fireEvent.keyDown( + viewItem0?.$element().get(0) as HTMLElement, + { key: 'End' }, + ); + + expect(wasDefaultPrevented).toBe(true); + }); + it('should move focus to last appointment on End key', () => { const viewModel = [ mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), @@ -908,7 +954,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -936,7 +982,7 @@ describe('Appointments', () => { ...getProperties(), allowDelete: true, onDeleteKeyPress, - getSortedAppointments: () => [{ + getSortedItems: () => [{ sortedIndex: 0, itemData: defaultAppointmentData, source: { startDate: 0 }, @@ -950,7 +996,7 @@ describe('Appointments', () => { expect(onDeleteKeyPress).toHaveBeenCalledTimes(1); expect(onDeleteKeyPress).toHaveBeenCalledWith( - expect.objectContaining({ appointment: defaultAppointmentData }), + expect.objectContaining({ appointmentData: defaultAppointmentData }), ); }); @@ -964,7 +1010,28 @@ describe('Appointments', () => { ...getProperties(), allowDelete: false, onDeleteKeyPress, - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Delete' }); + + expect(onDeleteKeyPress).not.toHaveBeenCalled(); + }); + + it('should not call onDeleteKeyPress when Delete is pressed on appointment collector', () => { + const onDeleteKeyPress = jest.fn(); + const viewModel = [ + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + allowDelete: true, + onDeleteKeyPress, + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -984,7 +1051,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), onItemActivate, - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -1006,7 +1073,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), onItemActivate, - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedItems: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 5e70e0c5d234..b7314b4803ab 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -64,7 +64,7 @@ export interface AppointmentsProperties extends DOMComponentProperties ResourceManager; getDataAccessor: () => AppointmentDataAccessor; getStartViewDate: () => Date; - getSortedAppointments: () => SortedEntity[]; + getSortedItems: () => SortedEntity[]; isVirtualScrolling: () => boolean; scrollTo: (date: Date, options?: ScrollToOptions) => void; @@ -85,8 +85,11 @@ export interface AppointmentsProperties extends DOMComponentProperties void; - onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; + { appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment }) => void; + onItemActivate: (options: { + data: SafeAppointment; + targetedAppointmentData: TargetedAppointment; + }) => void; } export class Appointments extends DOMComponent { diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 7f1057e69fb8..89b20dc2940c 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -501,7 +501,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.initEditing(); const { editing } = this; - this.bringEditingModeToAppointments(editing); + if (this.option('_newAppointments')) { + this._appointments.option('allowDelete', this.editing.allowDeleting); + } else { + this.bringEditingModeToAppointments(editing); + } this.hideAppointmentTooltip(); this.createAppointmentPopupForm(); @@ -1062,7 +1066,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { const appointmentsConfig: Partial = { tabIndex: this.option('tabIndex'), currentView: this.option('currentView') as ViewType, - allowDelete: this.editing.allowUpdating && this.editing.allowDeleting, + allowDelete: this.editing.allowDeleting, appointmentTemplate: this.getViewOption('appointmentTemplate'), appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), @@ -1070,15 +1074,18 @@ class Scheduler extends SchedulerOptionsBaseWidget { onAppointmentClick: (...args) => this.actions.onAppointmentClick(...args), onAppointmentDblClick: (...args) => this.actions.onAppointmentDblClick(...args), onDeleteKeyPress: (e) => { - this.checkAndDeleteAppointment(e.appointment, e.occurrence); + this.checkAndDeleteAppointment(e.appointmentData, e.targetedAppointmentData); + }, + onItemActivate: ({ data, targetedAppointmentData }) => { + this.showAppointmentPopup(data, undefined, targetedAppointmentData); }, - onItemActivate: ({ data }) => { this.showAppointmentPopup(data); }, getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, getDataAccessor: () => this._dataAccessors, getStartViewDate: () => this.getStartViewDate(), - getSortedAppointments: () => this._layoutManager.sortedItems, + getSortedItems: () => this._layoutManager.sortedItems, + isVirtualScrolling: () => this.isVirtualScrolling(), scrollTo: this.scrollTo.bind(this), @@ -1235,7 +1242,6 @@ class Scheduler extends SchedulerOptionsBaseWidget { targetedAppointment, this._dataAccessors, ); - const deletingOptions = this.fireOnAppointmentDeleting(appointment, targetedAdapter); this.checkRecurringAppointment( appointment, From 7a4f8a041a23756820551cc538e4057cc002a701 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Fri, 22 May 2026 16:11:00 +0400 Subject: [PATCH 6/8] refactor(scheduler): address KBN review feedback for appointments_new --- .../appointments.focus_controller.ts | 31 ++-- .../appointments_new/appointments.test.ts | 157 ++++++++++++++++-- .../appointments_new/appointments.ts | 4 +- 3 files changed, 164 insertions(+), 28 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 0388b4a13e71..a7ce62aa1228 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -10,6 +10,10 @@ import { AppointmentCollector } from './appointment_collector'; import type { Appointments } from './appointments'; import type { ViewItem } from './view_item'; +interface AppointmentsFocusControllerHandlers { + onAppointmentEnterKeyDown: (appointmentView: BaseAppointmentView, event: DxEvent) => void; +} + export class AppointmentsFocusController { private focusableSortedIndex = 0; @@ -27,7 +31,10 @@ export class AppointmentsFocusController { return this.appointments.option().tabIndex; } - constructor(private readonly appointments: Appointments) { } + constructor( + private readonly appointments: Appointments, + private readonly handlers: AppointmentsFocusControllerHandlers, + ) { } public onViewItemClick(viewItem: ViewItem): void { this.focusViewItem(viewItem); @@ -119,12 +126,9 @@ export class AppointmentsFocusController { const { allowDelete, onDeleteKeyPress } = this.appointments.option(); if (!allowDelete) { return; } - const sortedItem = this.sortedAppointments[viewItem.option().sortedIndex]; - if (!sortedItem) { return; } - const appointmentViewItem = viewItem as BaseAppointmentView; onDeleteKeyPress({ - appointmentData: sortedItem.itemData, + appointmentData: appointmentViewItem.appointmentData, targetedAppointmentData: appointmentViewItem.targetedAppointmentData, }); } @@ -146,15 +150,16 @@ export class AppointmentsFocusController { } private handleEnterKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { - const { onItemActivate } = this.appointments.option(); - const sortedItem = this.sortedAppointments[viewItem.option().sortedIndex]; - if (!sortedItem) { return; } e.originalEvent.preventDefault(); - const appointmentViewItem = viewItem as BaseAppointmentView; - onItemActivate({ - data: sortedItem.itemData, - targetedAppointmentData: appointmentViewItem.targetedAppointmentData, - }); + + if (viewItem instanceof AppointmentCollector) { + return; + } + + this.handlers.onAppointmentEnterKeyDown( + viewItem as BaseAppointmentView, + e.originalEvent as DxEvent, + ); } private focusBySortedItem(sortedItem: SortedEntity): void { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 4f59fc5e45c8..0c6d7c62c1b1 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -945,6 +945,48 @@ describe('Appointments', () => { expect(wasDefaultPrevented).toBe(true); }); + it('should prevent default browser behavior on Enter key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + + const wasDefaultPrevented = !fireEvent.keyDown( + viewItem?.$element().get(0) as HTMLElement, + { key: 'Enter' }, + ); + + expect(wasDefaultPrevented).toBe(true); + }); + + it('should prevent default browser behavior on Space key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + + const wasDefaultPrevented = !fireEvent.keyDown( + viewItem?.$element().get(0) as HTMLElement, + { key: ' ' }, + ); + + expect(wasDefaultPrevented).toBe(true); + }); + it('should move focus to last appointment on End key', () => { const viewModel = [ mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), @@ -1042,16 +1084,15 @@ describe('Appointments', () => { expect(onDeleteKeyPress).not.toHaveBeenCalled(); }); - it('should call onItemActivate when Enter is pressed', () => { - const onItemActivate = jest.fn(); + it('should show appointment popup when Enter is pressed', () => { + const showEditAppointmentPopup = jest.fn(); const viewModel = [ mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), ]; const instance = createAppointments({ ...getProperties(), - onItemActivate, - getSortedItems: () => viewModel as unknown as SortedEntity[], + showEditAppointmentPopup, }); instance.option('viewModel', viewModel); @@ -1059,21 +1100,44 @@ describe('Appointments', () => { (viewItem?.$element().get(0) as HTMLElement).click(); fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); - expect(onItemActivate).toHaveBeenCalledTimes(1); - expect(onItemActivate).toHaveBeenCalledWith( - expect.objectContaining({ data: defaultAppointmentData }), + expect(showEditAppointmentPopup).toHaveBeenCalledTimes(1); + expect(showEditAppointmentPopup).toHaveBeenCalledWith( + defaultAppointmentData, + expect.objectContaining({ ...defaultAppointmentData }), ); }); - it('should call onItemActivate when Space is pressed', () => { - const onItemActivate = jest.fn(); + + it('should call onAppointmentDblClick when Enter is pressed', () => { + const onAppointmentDblClick = jest.fn(); const viewModel = [ mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), ]; const instance = createAppointments({ ...getProperties(), - onItemActivate, - getSortedItems: () => viewModel as unknown as SortedEntity[], + onAppointmentDblClick, + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); + + expect(onAppointmentDblClick).toHaveBeenCalledTimes(1); + expect(onAppointmentDblClick).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: defaultAppointmentData }), + ); + }); + + it('should show appointment popup when Space is pressed', () => { + const showEditAppointmentPopup = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + showEditAppointmentPopup, }); instance.option('viewModel', viewModel); @@ -1081,11 +1145,76 @@ describe('Appointments', () => { (viewItem?.$element().get(0) as HTMLElement).click(); fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); - expect(onItemActivate).toHaveBeenCalledTimes(1); - expect(onItemActivate).toHaveBeenCalledWith( - expect.objectContaining({ data: defaultAppointmentData }), + expect(showEditAppointmentPopup).toHaveBeenCalledTimes(1); + expect(showEditAppointmentPopup).toHaveBeenCalledWith( + defaultAppointmentData, + expect.objectContaining({ ...defaultAppointmentData }), ); }); + + it('should call onAppointmentDblClick when Space is pressed', () => { + const onAppointmentDblClick = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + onAppointmentDblClick, + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); + + expect(onAppointmentDblClick).toHaveBeenCalledTimes(1); + expect(onAppointmentDblClick).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: defaultAppointmentData }), + ); + }); + + it('should show tooltip when Enter is pressed on appointment collector', () => { + const showTooltipForCollector = jest.fn(); + const showEditAppointmentPopup = jest.fn(); + const viewModel = [ + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + showTooltipForCollector, + showEditAppointmentPopup, + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); + + expect(showTooltipForCollector).toHaveBeenCalledTimes(1); + expect(showEditAppointmentPopup).not.toHaveBeenCalled(); + }); + + it('should show tooltip when Space is pressed on appointment collector', () => { + const showTooltipForCollector = jest.fn(); + const showEditAppointmentPopup = jest.fn(); + const viewModel = [ + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + showTooltipForCollector, + showEditAppointmentPopup, + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); + + expect(showTooltipForCollector).toHaveBeenCalledTimes(1); + expect(showEditAppointmentPopup).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index b7314b4803ab..e97e0e0851bb 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -120,7 +120,9 @@ export class Appointments extends DOMComponent Date: Fri, 22 May 2026 17:34:06 +0400 Subject: [PATCH 7/8] fix(scheduler): address code review feedback on appointments KBN --- .../appointments_new/appointments.focus_controller.ts | 8 ++++---- .../__internal/scheduler/appointments_new/appointments.ts | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index a7ce62aa1228..4a974c474540 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -19,7 +19,7 @@ export class AppointmentsFocusController { private needRestoreFocusIndex = -1; - private get sortedAppointments(): SortedEntity[] { + private get sortedItems(): SortedEntity[] { return this.appointments.option().getSortedItems(); } @@ -110,7 +110,7 @@ export class AppointmentsFocusController { private handleTabKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void { const nextIndex = sortedIndex + (e.shift ? -1 : 1); - const nextItemData = this.sortedAppointments[nextIndex]; + const nextItemData = this.sortedItems[nextIndex]; if (!nextItemData) { return; @@ -134,7 +134,7 @@ export class AppointmentsFocusController { } private handleHomeKeyDown(e: KeyboardKeyDownEvent): void { - const firstSortedItem = this.sortedAppointments[0]; + const firstSortedItem = this.sortedItems[0]; if (firstSortedItem) { e.originalEvent.preventDefault(); this.focusBySortedItem(firstSortedItem); @@ -142,7 +142,7 @@ export class AppointmentsFocusController { } private handleEndKeyDown(e: KeyboardKeyDownEvent): void { - const lastSortedItem = this.sortedAppointments[this.sortedAppointments.length - 1]; + const lastSortedItem = this.sortedItems[this.sortedItems.length - 1]; if (lastSortedItem) { e.originalEvent.preventDefault(); this.focusBySortedItem(lastSortedItem); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index e97e0e0851bb..5363678e6d17 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -84,8 +84,10 @@ export interface AppointmentsProperties extends DOMComponentProperties void; allowDelete: boolean; - onDeleteKeyPress: (options: - { appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment }) => void; + onDeleteKeyPress: (options: { + appointmentData: SafeAppointment; + targetedAppointmentData: TargetedAppointment; + }) => void; onItemActivate: (options: { data: SafeAppointment; targetedAppointmentData: TargetedAppointment; From be40bb7e84c27a20281171a6f60daacdc4515c88 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Fri, 22 May 2026 19:34:52 +0400 Subject: [PATCH 8/8] refactor(scheduler): remove onItemActivate from appointments_new KBN --- .../scheduler/appointments_new/appointments.test.ts | 1 - .../js/__internal/scheduler/appointments_new/appointments.ts | 5 ----- packages/devextreme/js/__internal/scheduler/m_scheduler.ts | 3 --- 3 files changed, 9 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 0c6d7c62c1b1..4cab9eee32f2 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -56,7 +56,6 @@ const getProperties = (options: { showEditAppointmentPopup: (): void => {}, allowDelete: false, onDeleteKeyPress: (): void => {}, - onItemActivate: (): void => {}, }); const createAppointments = ( diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 5363678e6d17..9200049538af 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -88,10 +88,6 @@ export interface AppointmentsProperties extends DOMComponentProperties void; - onItemActivate: (options: { - data: SafeAppointment; - targetedAppointmentData: TargetedAppointment; - }) => void; } export class Appointments extends DOMComponent { @@ -166,7 +162,6 @@ export class Appointments extends DOMComponent {}, - onItemActivate: (): void => {}, }; } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 89b20dc2940c..81e208647b33 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1076,9 +1076,6 @@ class Scheduler extends SchedulerOptionsBaseWidget { onDeleteKeyPress: (e) => { this.checkAndDeleteAppointment(e.appointmentData, e.targetedAppointmentData); }, - onItemActivate: ({ data, targetedAppointmentData }) => { - this.showAppointmentPopup(data, undefined, targetedAppointmentData); - }, getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource,