diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/outlookDragging/etalons/drag-short-app-to-right (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/outlookDragging/etalons/drag-short-app-to-right (fluent.blue.light).png index 5cec9d0042e2..bfa61325839e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/outlookDragging/etalons/drag-short-app-to-right (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/outlookDragging/etalons/drag-short-app-to-right (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts index e9e97e80d4d4..e21abaa877b1 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts @@ -1,98 +1,278 @@ import Scheduler from 'devextreme-testcafe-models/scheduler'; -import { ClientFunction } from 'testcafe'; import url from '../../../../helpers/getPageUrl'; import { createWidget } from '../../../../helpers/createWidget'; -import { getDocumentScrollTop } from '../../../../helpers/domUtils'; +import { generateAppointmentsWithResources, resources } from '../../helpers/generateAppointmentsWithResources'; +import { insertStylesheetRulesToPage } from '../../../../helpers/domUtils'; fixture.disablePageReloads`KeyboardNavigation.Appointments` .page(url(__dirname, '../../../container.html')); const SCHEDULER_SELECTOR = '#container'; -test('Document should not scroll on \'End\' press when appointment is focused', async (t) => { - const scheduler = new Scheduler(SCHEDULER_SELECTOR); +const resourceCount = 30; - await t.click(scheduler.getAppointment('Appointment 1').element); +const dataSource = generateAppointmentsWithResources({ + startDay: new Date(2021, 1, 1), + endDay: new Date(2021, 1, 6), + startDayHour: 8, + endDayHour: 20, + resourceCount, +}); - const expectedScrollTop = await getDocumentScrollTop(); +const appointmentCount = dataSource.length; - await t - .pressKey('End') - .expect(getDocumentScrollTop()).eql(expectedScrollTop); -}).before(async () => { - await ClientFunction(() => { - document.body.style.height = '2000px'; - })(); - - await createWidget('dxScheduler', { - dataSource: [ - { - text: 'Appointment 1', - startDate: new Date(2015, 1, 9, 8), - endDate: new Date(2015, 1, 9, 9), - }, - { - text: 'Appointment 2', - startDate: new Date(2015, 1, 9, 10), - endDate: new Date(2015, 1, 9, 11), - }, - { - text: 'Appointment 3', - startDate: new Date(2015, 1, 9, 12), - endDate: new Date(2015, 1, 9, 13), - }, - ], - height: 300, - currentView: 'day', - currentDate: new Date(2015, 1, 9), +const getConfig = () => ({ + views: [ + { + type: 'timelineWorkWeek', + name: 'Timeline', + groupOrientation: 'vertical', + }, + 'week', + ], + dataSource, + resources: [ + { fieldExpr: 'resourceId', label: 'Resource', dataSource: resources }, + ], + groups: ['resourceId'], + scrolling: { + mode: 'virtual', + }, + height: 600, + cellDuration: 60, + startDayHour: 8, + endDayHour: 20, + showAllDayPanel: false, + currentView: 'Timeline', + currentDate: new Date(2021, 1, 2), +}); + +const cellStyles = '#container .dx-scheduler-cell-sizes-vertical { height: 100px; } #container .dx-scheduler-cell-sizes-horizontal { width: 150px; }'; + +['virtual', 'standard'].forEach((scrollingMode) => { + test(`focus next appointment on single tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('tab'); + + await t + .expect(scheduler.getAppointment('[Appointment 2]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus next appointment on 5 tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('tab') + .pressKey('tab') + .pressKey('tab') + .pressKey('tab') + .pressKey('tab'); + + await t + .expect(scheduler.getAppointment('[Appointment 6]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus prev appointment on single shift+tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + const lastAppointmentText = `[Appointment ${appointmentCount}]`; + const prevAppointmentText = `[Appointment ${appointmentCount - 1}]`; + + await scheduler.scrollTo(new Date(2021, 1, 5), { resourceId: resourceCount }); + + await t + .click(scheduler.getAppointment(lastAppointmentText).element) + .pressKey('shift+tab'); + + await t + .expect(scheduler.getAppointment(prevAppointmentText).isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus prev appointment on 5 shift+tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + const lastAppointmentText = `[Appointment ${appointmentCount}]`; + const prevAppointmentText = `[Appointment ${appointmentCount - 5}]`; + + await scheduler.scrollTo(new Date(2021, 1, 5), { resourceId: resourceCount }); + + await t + .click(scheduler.getAppointment(lastAppointmentText).element) + .pressKey('shift+tab') + .pressKey('shift+tab') + .pressKey('shift+tab') + .pressKey('shift+tab') + .pressKey('shift+tab'); + + await t + .expect(scheduler.getAppointment(prevAppointmentText).isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus last appointment on End (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('end'); + + await t + .expect(scheduler.getAppointment(`[Appointment ${appointmentCount}]`).isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus first appointment on Home (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await scheduler.scrollTo(new Date(2021, 1, 5), { resourceId: resourceCount }); + + await t + .click(scheduler.getAppointment(`[Appointment ${appointmentCount}]`).element) + .pressKey('home'); + + await t + .expect(scheduler.getAppointment('[Appointment 1]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus first appointment in the next group by tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await scheduler.scrollTo(new Date(2021, 1, 5), { resourceId: 1 }); + + await t + .click(scheduler.getAppointment('[Appointment 14]').element) + .pressKey('tab'); + + await t + .expect(scheduler.getAppointment('[Appointment 15]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`focus last appointment in the prev group by shift+tab (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 15]').element) + .pressKey('shift+tab'); + await t + .expect(scheduler.getAppointment('[Appointment 14]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`should focus appointment after close edit popup (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('tab') + .pressKey('enter') + .pressKey('esc'); + + await t + .expect(scheduler.getAppointment('[Appointment 2]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`first appointment should be focusable when navigating by tab second time (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('tab') + .click(scheduler.toolbar.viewSwitcher.element) + .pressKey('tab') + .pressKey('tab'); + + await t + .expect(scheduler.getAppointment('[Appointment 1]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`should not reset scroll after appointment focus and scrolling down (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 1]').element) + .pressKey('tab') + .scroll(scheduler.workspaceScrollable, 0, 1000); + + await t.expect(scheduler.workspaceScrollable.scrollTop).eql(1000); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); + }); + + test(`should focus next appointment on tab after any appointment was clicked (${scrollingMode} scrolling)`, async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('[Appointment 15]').element) + .pressKey('tab'); + + await t + .expect(scheduler.getAppointment('[Appointment 16]').isFocused).ok(); + }).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: scrollingMode } }); }); -}).after(async () => { - await ClientFunction(() => { - document.body.style.height = ''; - })(); }); -test('Document should not scroll on \'Home\' press when appointment is focused', async (t) => { +test('should focus first visible appointment on tab (virtual scrolling)', async (t) => { const scheduler = new Scheduler(SCHEDULER_SELECTOR); await t - .scroll(0, 100) - .click(scheduler.getAppointment('Appointment 1').element); + .scroll(scheduler.workspaceScrollable, 0, 1000) + .click(scheduler.toolbar.viewSwitcher.element) + .pressKey('tab') + .pressKey('tab'); - const expectedScrollTop = await getDocumentScrollTop(); + await t + .expect(scheduler.getAppointment('[Appointment 135]').isFocused).ok(); +}).before(async () => { + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: 'virtual' } }); +}); + +test('should focus first rendered appointment on tab (standard scrolling)', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .scroll(scheduler.workspaceScrollable, 0, 1000) + .click(scheduler.toolbar.viewSwitcher.element) + .pressKey('tab') + .pressKey('tab'); await t - .pressKey('Home') - .expect(getDocumentScrollTop()).eql(expectedScrollTop); + .expect(scheduler.getAppointment('[Appointment 1]').isFocused).ok(); }).before(async () => { - await ClientFunction(() => { - document.body.style.height = '2000px'; - })(); - - await createWidget('dxScheduler', { - dataSource: [ - { - text: 'Appointment 1', - startDate: new Date(2015, 1, 9, 8), - endDate: new Date(2015, 1, 9, 9), - }, - { - text: 'Appointment 2', - startDate: new Date(2015, 1, 9, 10), - endDate: new Date(2015, 1, 9, 11), - }, - { - text: 'Appointment 3', - startDate: new Date(2015, 1, 9, 12), - endDate: new Date(2015, 1, 9, 13), - }, - ], - height: 300, - currentView: 'day', - currentDate: new Date(2015, 1, 9), - }); -}).after(async () => { - await ClientFunction(() => { - document.body.style.height = ''; - })(); + await insertStylesheetRulesToPage(cellStyles); + await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: 'standard' } }); }); diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/documentScrollPrevented.ts b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/documentScrollPrevented.ts new file mode 100644 index 000000000000..58c15fee1bb0 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/documentScrollPrevented.ts @@ -0,0 +1,98 @@ +import Scheduler from 'devextreme-testcafe-models/scheduler'; +import { ClientFunction } from 'testcafe'; +import url from '../../../../helpers/getPageUrl'; +import { createWidget } from '../../../../helpers/createWidget'; +import { getDocumentScrollTop } from '../../../../helpers/domUtils'; + +fixture.disablePageReloads`KeyboardNavigation.DocumentScrollPrevented` + .page(url(__dirname, '../../../container.html')); + +const SCHEDULER_SELECTOR = '#container'; + +test('Document should not scroll on \'End\' press when appointment is focused', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t.click(scheduler.getAppointment('Appointment 1').element); + + const expectedScrollTop = await getDocumentScrollTop(); + + await t + .pressKey('End') + .expect(getDocumentScrollTop()).eql(expectedScrollTop); +}).before(async () => { + await ClientFunction(() => { + document.body.style.height = '2000px'; + })(); + + await createWidget('dxScheduler', { + dataSource: [ + { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + { + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }, + { + text: 'Appointment 3', + startDate: new Date(2015, 1, 9, 12), + endDate: new Date(2015, 1, 9, 13), + }, + ], + height: 300, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); +}).after(async () => { + await ClientFunction(() => { + document.body.style.height = ''; + })(); +}); + +test('Document should not scroll on \'Home\' press when appointment is focused', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .scroll(0, 100) + .click(scheduler.getAppointment('Appointment 1').element); + + const expectedScrollTop = await getDocumentScrollTop(); + + await t + .pressKey('Home') + .expect(getDocumentScrollTop()).eql(expectedScrollTop); +}).before(async () => { + await ClientFunction(() => { + document.body.style.height = '2000px'; + })(); + + await createWidget('dxScheduler', { + dataSource: [ + { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + { + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }, + { + text: 'Appointment 3', + startDate: new Date(2015, 1, 9, 12), + endDate: new Date(2015, 1, 9, 13), + }, + ], + height: 300, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); +}).after(async () => { + await ClientFunction(() => { + document.body.style.height = ''; + })(); +}); diff --git a/e2e/testcafe-devextreme/tests/scheduler/helpers/generateAppointmentsWithResources.ts b/e2e/testcafe-devextreme/tests/scheduler/helpers/generateAppointmentsWithResources.ts new file mode 100644 index 000000000000..9ce59621e618 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/scheduler/helpers/generateAppointmentsWithResources.ts @@ -0,0 +1,65 @@ +const colors = [ + '#74d57b', '#1db2f5', '#f5564a', '#97c95c', '#ffc720', '#eb3573', + '#a63db8', '#ffaa66', '#2dcdc4', '#c34cb9', '#3d44ec', '#4ddcca', + '#2ec98d', '#ef9e44', '#45a5cc', '#a067bd', '#3d44ec', '#4ddcca', + '#3ff6ca', '#f665aa', '#d1c974', '#ff6741', '#ee53dc', '#795ac3', + '#ff7d8a', '#4cd482', '#9d67cc', '#5ab1ef', '#68e18f', '#4dd155', +]; + +export const resources = colors.map((color, index) => ({ text: `Resource ${index + 1}`, id: index + 1, color })); + +const getPseudoRandomDuration = (durationState: number): number => { + const durationMin = Math.floor((durationState % 23) / 3 + 5) * 15; + + return durationMin * 60 * 1000; +}; + +export const generateAppointmentsWithResources = ( + options: { + startDay: Date, + endDay: Date, + startDayHour: number, + endDayHour: number, + resourceCount?: number, + }, +): any[] => { + const { + startDay, endDay, startDayHour, endDayHour, + } = options; + const resourceCount = options.resourceCount ?? resources.length; + + if (resourceCount && resourceCount > resources.length) { + throw new Error(`Resource count should be less than or equal to ${resources.length}`); + } + + let appointments: any[] = []; + let durationState = 1; + const durationIncrement = 19; + + resources.slice(0, resourceCount).forEach((resource) => { + let startDate = startDay; + + while (startDate.getTime() < endDay.getTime()) { + durationState += durationIncrement; + const endDate = new Date(startDate.getTime() + getPseudoRandomDuration(durationState)); + + appointments.push({ + startDate, + endDate, + resourceId: resource.id, + }); + + durationState += durationIncrement; + startDate = new Date(endDate.getTime() + getPseudoRandomDuration(durationState)); + } + }); + + appointments = appointments.filter(({ startDate, endDate }) => ( + startDate.getDay() === endDate.getDay() + && startDate.getHours() >= startDayHour - 1 + && endDate.getHours() <= endDayHour - 1)); + + appointments = appointments.map((appointment, index) => ({ ...appointment, text: `[Appointment ${index + 1}]` })); + + return appointments; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts index f1a32bc4f6c3..278cdc41434c 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -43,6 +43,7 @@ import type { AppointmentCollectorViewModel, AppointmentItemViewModel, AppointmentViewModelPlain, + SortedEntity, } from '../view_model/types'; import { AgendaAppointment } from './appointment/agenda_appointment'; import { Appointment } from './appointment/m_appointment'; @@ -68,7 +69,7 @@ interface ViewModelDiff { class SchedulerAppointments extends CollectionWidget { // NOTE: The key of this array is `sortedIndex` of appointment rendered in Element - renderedElementsBySortedIndex: dxElementWrapper[] = []; + $itemBySortedIndex!: dxElementWrapper[]; _appointmentClickTimeout: any; @@ -82,17 +83,19 @@ class SchedulerAppointments extends CollectionWidget { private _kbn!: AppointmentsKeyboardNavigation; + private _focusedItemIndexBeforeRender!: number; + private _isResizing = false; public get isResizing(): boolean { return this._isResizing; } - get isAgendaView() { + get isAgendaView(): boolean { return this.invoke('isCurrentViewAgenda'); } - get isVirtualScrolling() { + get isVirtualScrolling(): boolean { return this.invoke('isVirtualScrolling'); } @@ -104,6 +107,10 @@ class SchedulerAppointments extends CollectionWidget { return this.option('dataAccessors') as AppointmentDataAccessor; } + get sortedItems(): SortedEntity[] { + return this.option('getSortedAppointments')() as SortedEntity[]; + } + getResourceManager(): ResourceManager { return this.option('getResourceManager')(); } @@ -138,7 +145,11 @@ class SchedulerAppointments extends CollectionWidget { const parentValue = super._supportedKeys(); const kbnValue = this._kbn.getSupportedKeys(); - return extend(parentValue, kbnValue) as SupportedKeys; + return { + enter: parentValue.enter, + space: parentValue.space, + ...kbnValue, + }; } public getAppointmentSettings($item: dxElementWrapper): AppointmentViewModelPlain { @@ -152,8 +163,30 @@ class SchedulerAppointments extends CollectionWidget { } _renderFocusTarget() { - const $item = this._kbn.getFocusableItemBySortedIndex(0); - this._kbn.resetTabIndex($item); + if (this.$itemBySortedIndex?.length) { + this._kbn.resetTabIndex(this._kbn.getFirstVisibleItem()); + } + } + + _cleanFocusState(): void { + this._focusedItemIndexBeforeRender = this._kbn.isNavigating + ? this._kbn.focusedItemSortIndex + : -1; + + super._cleanFocusState(); + } + + _renderFocusState(): void { + super._renderFocusState(); + + if (this._focusedItemIndexBeforeRender !== -1) { + this._kbn.focusedItemSortIndex = this._focusedItemIndexBeforeRender; + this._kbn.isNavigating = false; + this._kbn.focus(); + this._focusedItemIndexBeforeRender = -1; + } else { + this._kbn.focusedItemSortIndex = -1; + } } _focusInHandler(e) { @@ -192,7 +225,7 @@ class SchedulerAppointments extends CollectionWidget { value: AppointmentViewModelPlain[] = [], ): ViewModelDiff[] { const elementsInRenderOrder = previousValue - .map(({ sortedIndex }) => this.renderedElementsBySortedIndex[sortedIndex]); + .map(({ sortedIndex }) => this.$itemBySortedIndex[sortedIndex]); const diff = getViewModelDiff(previousValue, value, this.appointmentDataSource); diff .filter((item) => !isNeedToAdd(item)) @@ -206,7 +239,7 @@ class SchedulerAppointments extends CollectionWidget { _optionChanged(args) { switch (args.name) { case 'items': - (this as any)._cleanFocusState(); + this._cleanFocusState(); if (this.isAgendaView) { this.forceRepaintAllAppointments(args.value || []); @@ -225,7 +258,7 @@ class SchedulerAppointments extends CollectionWidget { case 'allowResize': case 'allowDelete': case 'allowAllDayResize': - (this as any)._cleanFocusState(); + this._cleanFocusState(); this.forceRepaintAllAppointments(this.option('items') || []); this._attachAppointmentsEvents(); break; @@ -251,7 +284,7 @@ class SchedulerAppointments extends CollectionWidget { } protected forceRepaintAllAppointments(items: AppointmentViewModelPlain[]): void { - this.renderedElementsBySortedIndex = []; + this.$itemBySortedIndex = []; this._renderByFragments(($commonFragment, $allDayFragment) => { this._getAppointmentContainer(true).html(''); this._getAppointmentContainer(false).html(''); @@ -269,10 +302,12 @@ class SchedulerAppointments extends CollectionWidget { } protected repaintAppointments(diff: ViewModelDiff[]): void { - this.renderedElementsBySortedIndex = []; + this.$itemBySortedIndex = []; + this._renderByFragments(($commonFragment, $allDayFragment) => { - const isRepaintAll = this.isAgendaView - || !diff.some((item) => item.needToAdd === undefined && item.needToRemove === undefined); + const isRepaintAll = diff.every( + (item) => Boolean(item.needToAdd ?? item.needToRemove), + ); if (isRepaintAll) { this._getAppointmentContainer(true).html(''); @@ -303,7 +338,7 @@ class SchedulerAppointments extends CollectionWidget { if (item.element) { item.element.data(APPOINTMENT_SETTINGS_KEY, item.item); - this.renderedElementsBySortedIndex[item.item.sortedIndex] = item.element; + this.$itemBySortedIndex[item.item.sortedIndex] = item.element; } }); }); @@ -334,14 +369,14 @@ class SchedulerAppointments extends CollectionWidget { } _attachAppointmentsEvents() { - (this as any)._attachClickEvent(); - (this as any)._attachHoldEvent(); - (this as any)._attachContextMenuEvent(); - (this as any)._attachAppointmentDblClick(); + this._attachClickEvent(); + this._attachHoldEvent(); + this._attachContextMenuEvent(); + this._attachAppointmentDblClick(); - (this as any)._renderFocusState(); - (this as any)._attachFeedbackEvents(); - (this as any)._attachHoverEvents(); + this._renderFocusState(); + this._attachFeedbackEvents(); + this._attachHoverEvents(); } _clearDropDownItemsElements() { @@ -389,8 +424,10 @@ class SchedulerAppointments extends CollectionWidget { _init() { super._init(); + this.$itemBySortedIndex = []; this._kbn = new AppointmentsKeyboardNavigation(this); - (this as any).$element().addClass(COMPONENT_CLASS); + this._focusedItemIndexBeforeRender = -1; + this.$element().addClass(COMPONENT_CLASS); this._preventSingleAppointmentClick = false; } @@ -519,12 +556,9 @@ class SchedulerAppointments extends CollectionWidget { const $item = super._renderItem(index, item.itemData, container); $item.data(APPOINTMENT_SETTINGS_KEY, item); + if (item.sortedIndex !== -1) { - // NOTE: fallback for integration testing - if (!this.renderedElementsBySortedIndex) { - this.renderedElementsBySortedIndex = []; - } - this.renderedElementsBySortedIndex[item.sortedIndex] = $item; + this.$itemBySortedIndex[item.sortedIndex] = $item; } return $item; @@ -673,7 +707,7 @@ class SchedulerAppointments extends CollectionWidget { const $appointment = $(e.element); this._isResizing = true; - this._kbn.$focusedItem = $appointment; + this._kbn.focus($appointment); if (this.invoke('needRecalculateResizableArea')) { const updatedArea = this._calculateResizableArea( @@ -983,13 +1017,13 @@ class SchedulerAppointments extends CollectionWidget { allowDrag: this.option('allowDrag'), isCompact: appointment.isCompact, }); - this.renderedElementsBySortedIndex[appointment.sortedIndex] = $item; + this.$itemBySortedIndex[appointment.sortedIndex] = $item; return $item; } moveAppointmentBack(dragEvent?) { - const $appointment = this._kbn.$focusedItem; + const $appointment = this._kbn.$focusTarget(); const size = this._initialSize; const coords = this._initialCoordinates; @@ -1005,7 +1039,7 @@ class SchedulerAppointments extends CollectionWidget { } } - if ($appointment && !dragEvent) { + if ($appointment.get(0) && !dragEvent) { if (coords) { move($appointment, coords); delete this._initialSize; diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts index 56c01578c029..690913835b5c 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts @@ -5,18 +5,22 @@ import { getPublicElement } from '@ts/core/m_element'; import type { SupportedKeys } from '@ts/core/widget/widget'; import eventsEngine from '@ts/events/core/m_events_engine'; +import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; +import type { SortedEntity } from '../view_model/types'; import type SchedulerAppointments from './m_appointment_collection'; -import { getNextElement, getPrevElement } from './utils/sorted_index_utils'; export class AppointmentsKeyboardNavigation { private readonly _collection: SchedulerAppointments; - public $focusedItem: dxElementWrapper | null = null; + public focusedItemSortIndex = -1; + + public isNavigating = false; constructor(collection: SchedulerAppointments) { this._collection = collection; } + // TODO: make disabled appointments focusable and remove this method public getFocusableItems(): dxElementWrapper { const appts = this._collection._itemElements().not('.dx-state-disabled'); const collectors = this._collection.$element().find('.dx-scheduler-appointment-collector'); @@ -24,34 +28,46 @@ export class AppointmentsKeyboardNavigation { return appts.add(collectors); } - public getFocusableItemBySortedIndex(sortedIndex: number): dxElementWrapper { - const $items = this.getFocusableItems(); - const itemElement = $items.toArray().filter((itemElement: Element) => { - const $item = $(itemElement); - const itemData = this._collection.getAppointmentSettings($item); - return itemData.sortedIndex === sortedIndex; - }); + public focus($item?: dxElementWrapper): void { + const $target = $item ?? this.$focusTarget(); - return $(itemElement); + if ($target.length) { + eventsEngine.trigger($target, 'focus'); + } } - public focus(): void { - if (this.$focusedItem) { - const focusedElement = getPublicElement(this.$focusedItem); + public $focusTarget(): dxElementWrapper { + const $items = this._collection.$itemBySortedIndex; - this._collection.option('focusedElement', focusedElement); - eventsEngine.trigger(focusedElement, 'focus'); + if (!$items) { + return $(); } + + const $item = $items[this.focusedItemSortIndex]; + return $item || $(); + } + + public resetTabIndex($item?: dxElementWrapper): void { + const $target = $item ?? this.$focusTarget(); + + this.getFocusableItems().attr('tabIndex', -1); + $target.attr('tabIndex', this._collection.option('tabIndex')); } public focusInHandler(e: DxEvent): void { - this.$focusedItem = $(e.target); - this._collection.option('focusedElement', getPublicElement(this.$focusedItem)); + const $target = $(e.target); + const itemData = this._collection.getAppointmentSettings($target); + + if (!itemData) { + return; + } + + this.focusedItemSortIndex = itemData.sortedIndex; + this._collection.option('focusedElement', getPublicElement(e.target)); } public focusOutHandler(): void { - const $item = this.getFocusableItemBySortedIndex(0); - this._collection.option('focusedElement', getPublicElement($item)); + this._collection.option('focusedElement', getPublicElement(this.getFirstVisibleItem())); } public getSupportedKeys(): SupportedKeys { @@ -64,35 +80,6 @@ export class AppointmentsKeyboardNavigation { }; } - public resetTabIndex($appointment: dxElementWrapper): void { - this.getFocusableItems().attr('tabIndex', -1); - $appointment.attr('tabIndex', this._collection.option('tabIndex')); - } - - private tabHandler(e): void { - if (!this.$focusedItem) { - return; - } - - const $focusableItems = this.getFocusableItems(); - let index = this._collection.getAppointmentSettings(this.$focusedItem).sortedIndex; - let $nextAppointment = e.shiftKey - ? getPrevElement(index, this._collection.renderedElementsBySortedIndex) - : getNextElement(index, this._collection.renderedElementsBySortedIndex); - const lastIndex = $focusableItems.length - 1; - - if ($nextAppointment || (index > 0 && e.shiftKey) || (index < lastIndex && !e.shiftKey)) { - e.preventDefault(); - - if (!$nextAppointment) { - e.shiftKey ? index-- : index++; - $nextAppointment = this.getFocusableItemBySortedIndex(index); - } - - this.focusItem($nextAppointment); - } - } - private delHandler(e: DxEvent): void { if (this._collection.option('allowDelete')) { e.preventDefault(); @@ -108,7 +95,7 @@ export class AppointmentsKeyboardNavigation { this._collection.moveAppointmentBack(); - const resizableInstance = (this.$focusedItem as any).dxResizable('instance'); + const resizableInstance = (this.$focusTarget() as any).dxResizable('instance'); if (resizableInstance) { resizableInstance._detachEventHandlers(); @@ -117,32 +104,93 @@ export class AppointmentsKeyboardNavigation { } } - private homeHandler(e: DxEvent): void { + private tabHandler(e: DxEvent): void { + const items = this._collection.sortedItems; + const nextIndex = this.focusedItemSortIndex + (e.shiftKey ? -1 : 1); + const nextItemData = items[nextIndex]; + + if (!nextItemData) { + return; + } + e.preventDefault(); + this.focusByItemData(nextItemData); + } - const $firstItem = this.getFocusableItems().first(); + private homeHandler(e: DxEvent): void { + const items = this._collection.sortedItems; + const nextItemData = items[0]; - if (this.$focusedItem && $firstItem.is(this.$focusedItem)) { + if (!nextItemData) { return; } - this.focusItem($firstItem); + e.preventDefault(); + this.focusByItemData(nextItemData); } - private endHandler(e: DxEvent): void { + private endHandler(e: DxEvent): void { + const items = this._collection.sortedItems; + const nextItemData = items[items.length - 1]; + + if (!nextItemData) { + return; + } + e.preventDefault(); + this.focusByItemData(nextItemData); + } - const $lastItem = this.getFocusableItems().last(); + private focusByItemData(itemData: SortedEntity): void { + this.focusedItemSortIndex = itemData.sortedIndex; - if (this.$focusedItem && $lastItem.is(this.$focusedItem)) { - return; + if (this._collection.isVirtualScrolling) { + this.isNavigating = true; + this.scrollToByItemData(itemData); } - this.focusItem($lastItem); + this.focus(); + } + + private scrollToByItemData(itemData: SortedEntity): void { + const date = new Date(Math.max( + this._collection.invoke('getStartViewDate').getTime(), + itemData.source.startDate, + )); + + const group = getRawAppointmentGroupValues( + itemData.itemData, + this._collection.getResourceManager().resources, + ); + + this._collection.option('scrollTo')( + date, + { + group, + allDay: itemData.allDay, + }, + ); } - private focusItem($item: dxElementWrapper): void { - this.resetTabIndex($item); - eventsEngine.trigger($item, 'focus'); + public getFirstVisibleItem(): dxElementWrapper { + const $items = this._collection.$itemBySortedIndex; + const $itemsPlainArray = Object.values($items); + + const $firstItem = this._collection.isVirtualScrolling + ? $itemsPlainArray.find(($item) => this.isItemVisibleInViewport($item)) ?? $() + : $($itemsPlainArray[0]); + + return $firstItem; + } + + private isItemVisibleInViewport($item: dxElementWrapper): boolean { + const $container = this._collection.$element().closest('.dx-scrollable-container'); + const containerRect = $container.get(0).getBoundingClientRect(); + const itemRect = $item.get(0).getBoundingClientRect(); + + return (itemRect.top < containerRect.bottom + && itemRect.bottom > containerRect.top + && itemRect.left < containerRect.right + && itemRect.right > containerRect.left); } } diff --git a/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.test.ts b/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.test.ts deleted file mode 100644 index dea90ab102e7..000000000000 --- a/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - describe, expect, it, jest, -} from '@jest/globals'; -import $ from '@js/core/renderer'; - -import { getNextElement, getPrevElement, isElementCanBeFocused } from './sorted_index_utils'; - -const createContainer = () => { - const container = document.createElement('div'); - const $element = $(container); - jest.spyOn($element, 'is').mockImplementation((selector) => selector === ':visible'); - return $element; -}; -const createDisabledContainer = () => { - const $container = createContainer(); - $container.addClass('dx-state-disabled'); - return $container; -}; - -describe('sorted index utils', () => { - describe('isElementCanBeFocused', () => { - it('should return true for pure div', () => { - expect(isElementCanBeFocused(createContainer())).toBe(true); - }); - - it('should return false for invisible div', () => { - const container = document.createElement('div'); - expect(isElementCanBeFocused($(container))).toBe(false); - }); - - it('should return false for disabled div', () => { - expect(isElementCanBeFocused(createDisabledContainer())).toBe(false); - }); - }); - - describe('getPrevElement', () => { - it('should return prev element', () => { - const elements = [ - createContainer(), - createContainer(), - createContainer(), - ]; - - expect(getPrevElement(2, elements)).toBe(elements[1]); - }); - - it('should return prev element that exist and not disabled', () => { - const elements = [ - createContainer(), - undefined, - createDisabledContainer(), - createContainer(), - ]; - - expect(getPrevElement(3, elements as any)).toBe(elements[0]); - }); - - it('should return undefined', () => { - const elements = [ - createContainer(), - ]; - - expect(getPrevElement(0, elements)).toBe(undefined); - }); - }); - - describe('getNextElement', () => { - it('should return next element', () => { - const elements = [ - createContainer(), - createContainer(), - createContainer(), - ]; - - expect(getNextElement(2, elements)).toBe(elements[3]); - }); - - it('should return next element that exist and not disabled', () => { - const elements = [ - createContainer(), - undefined, - createDisabledContainer(), - createContainer(), - ]; - - expect(getNextElement(0, elements as any)).toBe(elements[3]); - }); - - it('should return undefined', () => { - const elements = [ - createContainer(), - ]; - - expect(getNextElement(0, elements)).toBe(undefined); - }); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.ts b/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.ts deleted file mode 100644 index 8e78379255a0..000000000000 --- a/packages/devextreme/js/__internal/scheduler/appointments/utils/sorted_index_utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { dxElementWrapper } from '@js/core/renderer'; - -export const isElementCanBeFocused = ($element: dxElementWrapper): boolean => Boolean( - $element && $element.is(':visible') && !$element.hasClass('dx-state-disabled'), -); - -export const getPrevElement = ( - sortedIndex: number, - renderedElementsBySortedIndex: dxElementWrapper[] = [], -): dxElementWrapper | undefined => { - let index = sortedIndex - 1; - while (index >= 0) { - const $nextElement = renderedElementsBySortedIndex[index]; - if (isElementCanBeFocused($nextElement)) { - return $nextElement; - } - index -= 1; - } - - return undefined; -}; - -export const getNextElement = ( - sortedIndex: number, - renderedElementsBySortedIndex: dxElementWrapper[] = [], -): dxElementWrapper | undefined => { - let index = sortedIndex + 1; - while (index < renderedElementsBySortedIndex.length) { - const $nextElement = renderedElementsBySortedIndex[index]; - if (isElementCanBeFocused($nextElement)) { - return $nextElement; - } - index += 1; - } - - return undefined; -}; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index c11ecde06dd8..54dfd208d99d 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -875,6 +875,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._appointments.option('items', viewModel); this.appointmentDataSource.cleanState(); + if (this.isAgenda()) { this._workSpace.renderAgendaLayout(viewModel); } @@ -1235,8 +1236,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { private appointmentsConfig() { const config = { getResourceManager: () => this.resourceManager, - getAppointmentDataSource: () => this.appointmentDataSource, + getSortedAppointments: () => this._layoutManager.sortedItems, + scrollTo: this.scrollTo.bind(this), dataAccessors: this._dataAccessors, notifyScheduler: this.notifyScheduler, onItemRendered: this.getAppointmentRenderedAction(), @@ -1373,9 +1375,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { schedulerWidth: this.option('width'), allDayPanelMode: this.option('allDayPanelMode'), onSelectedCellsClick: this.showAddAppointmentPopup.bind(this), - onRenderAppointments: () => { - this.renderAppointments(); - }, + renderAppointments: () => { this.renderAppointments(); }, onShowAllDayPanel: (value) => this.option('showAllDayPanel', value), getHeaderHeight: () => utils.DOM.getHeaderHeight(this.header), onScrollEnd: () => this._appointments.updateResizableArea(), diff --git a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts index 93d74fb25e02..6144f11a012e 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts @@ -4,7 +4,8 @@ import type Scheduler from '../m_scheduler'; import { filterAppointments } from './filtration/filter_appointments'; import { getOccurrences } from './filtration/get_occurrences'; import { generateAgendaViewModel } from './generate_view_model/generate_agenda_view_model'; -import { generateGridViewModel } from './generate_view_model/generate_grid_view_model'; +import { generateGridViewModel, sortAppointments } from './generate_view_model/generate_grid_view_model'; +import { OptionManager } from './generate_view_model/options/option_manager'; import type { RealSize } from './generate_view_model/steps/add_geometry/types'; import { getAgendaAppointmentInfo, getAppointmentInfo } from './get_appointment_info'; import { prepareAppointments } from './preparation/prepare_appointments'; @@ -15,13 +16,20 @@ import type { AppointmentViewModelPlain, ListEntity, MinimalAppointmentEntity, + SortedEntity, UTCDatesBeforeSplit, } from './types'; class AppointmentLayoutManager { - preparedItems: MinimalAppointmentEntity[] = []; + private preparedItems: MinimalAppointmentEntity[] = []; - filteredItems: ListEntity[] = []; + private _filteredItems: ListEntity[] = []; + + public get filteredItems(): ListEntity[] { return this._filteredItems; } + + private _sortedItems: SortedEntity[] = []; + + public get sortedItems(): SortedEntity[] { return this._sortedItems; } // NOTE: Here we should pass global store. But right now scheduler component is global store constructor(public schedulerStore: Scheduler) {} @@ -31,7 +39,7 @@ class AppointmentLayoutManager { } public filterAppointments(): void { - this.filteredItems = filterAppointments(this.schedulerStore, this.preparedItems); + this._filteredItems = filterAppointments(this.schedulerStore, this.preparedItems); } public getOccurrences( @@ -51,13 +59,13 @@ class AppointmentLayoutManager { } public hasAllDayAppointments(): boolean { - return this.filteredItems.filter((item: ListEntity) => item.isAllDayPanelOccupied).length > 0; + return this._filteredItems.filter((item: ListEntity) => item.isAllDayPanelOccupied).length > 0; } public generateViewModel(): AppointmentViewModelPlain[] { const viewType = this.schedulerStore.currentView.type; if (viewType === 'agenda') { - const viewModel = generateAgendaViewModel(this.schedulerStore, this.filteredItems); + const viewModel = generateAgendaViewModel(this.schedulerStore, this._filteredItems); return viewModel.map((item) => ({ ...item, isAgendaModel: true, @@ -65,10 +73,19 @@ class AppointmentLayoutManager { })); } + const optionManager = new OptionManager(this.schedulerStore); + + this._sortedItems = sortAppointments(optionManager, this._filteredItems); + + const viewModel = generateGridViewModel( + this.schedulerStore, + optionManager, + this._sortedItems, + ); + const isSkipResizing = (appointment: ListEntity): boolean => appointment.isAllDayPanelOccupied && viewType === 'day' && this.schedulerStore.currentView.intervalCount === 1; - const viewModel = generateGridViewModel(this.schedulerStore, this.filteredItems); const toItem = (item: AppointmentEntity): AppointmentItemViewModel => ({ itemData: item.itemData, allDay: item.isAllDayPanelOccupied, @@ -100,6 +117,7 @@ class AppointmentLayoutManager { height: item.height, info: getAppointmentInfo(item), } as unknown as AppointmentItemViewModel); + return viewModel.map((item) => { if (item.items.length) { return { diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts index b91034f26441..8a64148ad289 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/generate_grid_view_model.ts @@ -1,6 +1,6 @@ import type Scheduler from '../../m_scheduler'; -import type { AppointmentEntity, ListEntity } from '../types'; -import { OptionManager } from './options/option_manager'; +import type { AppointmentEntity, ListEntity, SortedEntity } from '../types'; +import type { OptionManager } from './options/option_manager'; import { addCollector } from './steps/add_collector/add_collector'; import { addDirection } from './steps/add_direction'; import { addEmptiness } from './steps/add_emptiness'; @@ -16,22 +16,16 @@ import { splitByParts } from './steps/split_by_parts/split_by_parts'; import { cropByVirtualScreen } from './steps/virtual_screen_crop'; import { filterByVirtualScreen } from './steps/virtual_screen_filter'; -export const generateGridViewModel = ( - schedulerStore: Scheduler, +export const sortAppointments = ( + optionManager: OptionManager, items: ListEntity[], -): AppointmentEntity[] => { - const optionManager = new OptionManager(schedulerStore); +): SortedEntity[] => { const { - viewOrientation, isMonthView, - isAdaptivityEnabled, - isTimelineView, hasAllDayPanel, - isVirtualScrolling, viewOffset, compareOptions: { endDayHour }, } = optionManager.options; - const { viewDataProvider } = schedulerStore._workSpace; const step2 = maybeSplit(items, hasAllDayPanel, (entities, panelName) => { const byGroup = groupByGroupIndex(entities); @@ -57,8 +51,27 @@ export const generateGridViewModel = ( }); const step3 = addSortedIndex(step2); + + return step3; +}; + +export const generateGridViewModel = ( + schedulerStore: Scheduler, + optionManager: OptionManager, + items: SortedEntity[], +): AppointmentEntity[] => { + const { + viewOrientation, + isMonthView, + isAdaptivityEnabled, + isTimelineView, + hasAllDayPanel, + isVirtualScrolling, + } = optionManager.options; + const { viewDataProvider } = schedulerStore._workSpace; + const step4 = filterByVirtualScreen( - step3, + items, viewDataProvider, isVirtualScrolling, ); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/types.ts b/packages/devextreme/js/__internal/scheduler/view_model/types.ts index 84fc9e34853d..591d2be66ccc 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/types.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/types.ts @@ -149,14 +149,16 @@ export interface Direction { direction: Orientation; } -export type AppointmentEntity = ListEntity - & UTCDatesBeforeSplit +export type SortedEntity = ListEntity & AppointmentPart - & Level & Position + & Level + & AppointmentCollector + & SortedIndex; + +export type AppointmentEntity = SortedEntity & Direction & Empty - & SortedIndex & Geometry & AppointmentCollectorWithGeometry; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts index 944f7e3b8616..55523a568f96 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts @@ -691,11 +691,11 @@ export class VirtualScrollingRenderer { clearTimeout(this._renderAppointmentTimeoutID); this._renderAppointmentTimeoutID = setTimeout( - () => this.workspace.updateAppointments(), + () => this.workspace.renderAppointments(), renderTimeout, ); } else { - this.workspace.updateAppointments(); + this.workspace.renderAppointments(); } } } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index df578104a6a2..3ce1501dc7e4 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -2285,7 +2285,7 @@ class SchedulerWorkSpace extends Widget { draggingMode: 'outlook', onScrollEnd: () => {}, getHeaderHeight: undefined, - onRenderAppointments: () => {}, + renderAppointments: () => {}, onShowAllDayPanel: () => {}, onSelectedCellsClick: () => {}, timeZoneCalculator: undefined, @@ -2363,7 +2363,7 @@ class SchedulerWorkSpace extends Widget { break; case 'allDayPanelMode': this.updateShowAllDayPanel(); - this.updateAppointments(); + this.renderAppointments(); break; case 'width': // @ts-expect-error @@ -2888,8 +2888,8 @@ class SchedulerWorkSpace extends Widget { this.renderer._renderGrid(); } - updateAppointments() { - (this.option('onRenderAppointments') as any)(); + renderAppointments() { + (this.option('renderAppointments') as any)(); this.dragBehavior?.updateDragSource(); } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js index 8180556ae3cb..b8b92505bdcb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js @@ -8,14 +8,10 @@ import { getEmptyResourceManager } from '../../helpers/scheduler/mockResourceMan import $ from 'jquery'; import '__internal/scheduler/workspaces/m_work_space_week'; import SchedulerAppointments from '__internal/scheduler/appointments/m_appointment_collection'; -import eventsEngine from 'common/core/events/core/events_engine'; import dblclickEvent from 'common/core/events/dblclick'; import translator from 'common/core/animation/translator'; -import { isRenderer } from 'core/utils/type'; -import config from 'core/config'; import Resizable from 'ui/resizable'; import fx from 'common/core/animation/fx'; -import { DataSource } from 'common/data/data_source/data_source'; import { Deferred } from 'core/utils/deferred'; import { createTimeZoneCalculator } from '__internal/scheduler/r1/timezone_calculator/index.js'; @@ -88,6 +84,10 @@ const createInstance = (options, subscribesConfig) => { } }; + // Set 'items' using options like it is done in real scheduler + const items = options.items || []; + delete options.items; + const instance = $('#scheduler-appointments').dxSchedulerAppointments({ notifyScheduler, ...options, @@ -95,6 +95,7 @@ const createInstance = (options, subscribesConfig) => { getLoadedResources: () => [], getResourceManager: getEmptyResourceManager, getAppointmentColor: () => new Deferred(), + getSortedAppointments: () => items, dataAccessors, getAppointmentDataSource: () => ({ getUpdatedAppointment: () => false, @@ -109,6 +110,8 @@ const createInstance = (options, subscribesConfig) => { workspaceInstance.getWorkArea().append(instance.$element()); + instance.option('items', items); + const schedulerMock = { _appointments: instance, option: $.noop, @@ -154,7 +157,7 @@ QUnit.module('Appointments', moduleOptions, () => { items: [ { itemData: data, - sortedIndex: -1, + sortedIndex: 0, }, ], }, testConfig); @@ -173,7 +176,7 @@ QUnit.module('Appointments', moduleOptions, () => { items: [ { itemData: data, - sortedIndex: -1, + sortedIndex: 0, } ], }, testConfig); @@ -189,14 +192,14 @@ QUnit.module('Appointments', moduleOptions, () => { text: 'Appointment 1', startDate: new Date() }, - sortedIndex: -1, + sortedIndex: 0, }, { itemData: { text: 'Appointment 2', startDate: new Date() }, - sortedIndex: -1, + sortedIndex: 1, } ], }, testConfig); @@ -214,7 +217,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(), recurrenceRule: 'FREQ=YEARLY;COUNT=1' }, - sortedIndex: -1, + sortedIndex: 0, } ], }, testConfig); @@ -232,7 +235,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, height: 40, } ], @@ -254,7 +257,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, height: 30, } ]); @@ -279,7 +282,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, } ], allowResize: false, @@ -297,7 +300,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, height: 40, width: 40, }; @@ -400,7 +403,7 @@ QUnit.module('Appointments', moduleOptions, () => { endDate: new Date(2015, 1, 9, 9), allDay: true }, - sortedIndex: -1, + sortedIndex: 0, allDay: true, }; @@ -454,7 +457,7 @@ QUnit.module('Appointments', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, }; const instance = createInstance({ @@ -475,7 +478,7 @@ QUnit.module('Appointments Actions', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, }; const instance = createInstance({ @@ -502,20 +505,18 @@ QUnit.module('Appointments Actions', moduleOptions, () => { startDate: new Date(2015, 2, 9, 10), endDate: new Date(2015, 2, 9, 10) }, - sortedIndex: -1, + sortedIndex: 0, }, { itemData: { text: 'Appointment 2', startDate: new Date(2015, 2, 10, 8), endDate: new Date(2015, 2, 10, 9) }, - sortedIndex: -1, + sortedIndex: 1, }]; createInstance({ - dataSource: new DataSource({ - store: items - }), + items, views: ['month'], currentView: 'month', currentDate: new Date(2015, 2, 9), @@ -537,7 +538,7 @@ QUnit.module('Appointments Actions', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) }, - sortedIndex: -1, + sortedIndex: 0, }; const instance = createInstance({ @@ -586,7 +587,7 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { assert.ok(!$appointments.eq(0).attr('tabindex'), 'item tabindex is right'); }); - QUnit.testInActiveWindow('Focused element should be changed on focusin', async function(assert) { + QUnit.test('Focused element should be changed on focusin', async function(assert) { const items = [ { itemData: { @@ -594,7 +595,7 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 10) }, - sortedIndex: -1, + sortedIndex: 0, }, { itemData: { @@ -602,7 +603,7 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { startDate: new Date(2015, 1, 9, 9), endDate: new Date(2015, 1, 9, 10) }, - sortedIndex: -1, + sortedIndex: 1, } ]; @@ -613,12 +614,12 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { }, keyboardNavigationConfig); const $appointments = $('.dx-scheduler-appointment'); + $appointments.get(0).focus(); - assert.equal(isRenderer(instance.option('focusedElement')), !!config().useJQuery, 'focusedElement is correct'); - assert.deepEqual($appointments.get(0), $(instance.option('focusedElement')).get(0), 'right element is focused'); + assert.equal($appointments.get(0), $(instance.option('focusedElement')).get(0), 'right element is focused - 1'); $appointments.get(1).focus(); - assert.deepEqual($appointments.get(1), $(instance.option('focusedElement')).get(0), 'right element is focused'); + assert.equal($appointments.get(1), $(instance.option('focusedElement')).get(0), 'right element is focused - 2'); }); QUnit.test('Appointment popup should be opened after enter key press', async function(assert) { @@ -756,7 +757,7 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { startDate: new Date(2015, 10, 3, 9), endDate: new Date(2015, 10, 3, 11) }, - sortedIndex: -1 + sortedIndex: 0 } ]; @@ -768,22 +769,18 @@ QUnit.module('Appointments Keyboard Navigation', moduleOptions, () => { const $appointment = $('.dx-scheduler-appointment').eq(0); - $($appointment).trigger('focusin'); - const initialTrigger = eventsEngine.trigger; + $appointment.trigger('focusin'); - const focusedElement = $(instance.option('focusedElement')).get(0); - const focusSpy = sinon.spy(eventsEngine, 'trigger').withArgs(sinon.match(function($element) { - return config().useJQuery ? $element.get(0) === focusedElement : $element === focusedElement; - }), 'focus'); + let focusCalled = false; + $appointment.on('focus', function() { + focusCalled = true; + }); instance.focus(); - - assert.ok(focusSpy.called, 'focus is called'); - sinon.restore(); - - eventsEngine.trigger = initialTrigger; + assert.ok(focusCalled, 'focus is called'); }); + QUnit.test('Default behavior of tab button should be prevented for apps', async function(assert) { assert.expect(1); diff --git a/packages/testcafe-models/scheduler/appointment/index.ts b/packages/testcafe-models/scheduler/appointment/index.ts index 92f6e62e198e..342a24d22990 100644 --- a/packages/testcafe-models/scheduler/appointment/index.ts +++ b/packages/testcafe-models/scheduler/appointment/index.ts @@ -55,9 +55,10 @@ export default class Appointment { reducedIcon: Selector; - constructor(scheduler: Selector, index = 0, title?: string) { + constructor(scheduler: Selector, index = 0, text?: string) { const element = scheduler.find(`.${CLASS.appointment}`); - this.element = (title ? element.withText(title) : element).nth(index); + + this.element = (text ? element.withText(text) : element).nth(index); const appointmentContentDate = this.element.find(`.${CLASS.appointmentContentDate}`);