diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/recurrences/etalons/recurrence-delete-dialog-screenshot (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/recurrences/etalons/recurrence-delete-dialog-screenshot (fluent.blue.light).png index 9d1a697c0d3c..e871e99b5098 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/recurrences/etalons/recurrence-delete-dialog-screenshot (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/recurrences/etalons/recurrence-delete-dialog-screenshot (fluent.blue.light).png differ diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/m_mock_scheduler.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/m_mock_scheduler.ts index 7a6fd5c14896..47e32a042e14 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/m_mock_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/m_mock_scheduler.ts @@ -70,4 +70,8 @@ export const setupSchedulerTestEnvironment = ({ return defaultRect; }); + + Element.prototype.getClientRects = jest.fn(function (): DOMRectList { + return [Element.prototype.getBoundingClientRect.call(this)] as unknown as DOMRectList; + }); }; diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts index 6444af4f4e57..987ae61b8c31 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts @@ -1,12 +1,28 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type dxTooltip from '@js/ui/tooltip'; import { within } from '@testing-library/dom'; -const TOOLTIP_WRAPPER_SELECTOR = '.dx-overlay-wrapper.dx-scheduler-appointment-tooltip-wrapper'; +const TOOLTIP_WRAPPER_SELECTOR = ` + .dx-overlay-wrapper.dx-scheduler-overlay-panel, + .dx-overlay-wrapper.dx-scheduler-appointment-tooltip-wrapper +`; export class TooltipModel { - private get element(): HTMLElement | null { + get element(): HTMLElement | null { return document.querySelector(TOOLTIP_WRAPPER_SELECTOR); } + get dxTooltip(): dxTooltip { + // @ts-expect-error + return $('.dx-tooltip.dx-widget').dxTooltip('instance') as dxTooltip; + } + + get target(): Element | null { + const $target = this.dxTooltip.option('target') as unknown as dxElementWrapper; + return $target?.get(0) ?? null; + } + isVisible(): boolean { return this.element !== null; } @@ -15,24 +31,35 @@ export class TooltipModel { return this.element?.querySelector('.dx-scrollable .dx-scrollview-content') ?? null; } - getDeleteButton(index = 0): HTMLElement { - const tooltip = this.element; - const buttons = tooltip - ? within(tooltip).queryAllByRole('button').filter((btn) => btn.classList.contains('dx-tooltip-appointment-item-delete-button')) + getDeleteButtons(): HTMLElement[] { + return this.element + ? within(this.element).queryAllByRole('button').filter( + (btn) => btn.classList.contains('dx-tooltip-appointment-item-delete-button'), + ) : []; + } - if (buttons.length === 0) { + getDeleteButton(index = 0): HTMLElement { + const buttons = this.getDeleteButtons(); + + if (buttons.length <= index) { throw new Error('Tooltip delete button not found'); } return buttons[index]; } + getAppointmentItems(): HTMLElement[] { + return this.element ? within(this.element).queryAllByRole('option') : []; + } + getAppointmentItem(index = 0): HTMLElement | null { - const tooltip = this.element; - if (!tooltip) { - return null; + const items = this.getAppointmentItems(); + + if (items.length <= index) { + throw new Error('Tooltip appointment item not found'); } - return within(tooltip).queryAllByRole('option')[index] ?? null; + + return items[index]; } } diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointment_tooltip.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointment_tooltip.test.ts index f1d2d4b7a513..3d91c255754e 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointment_tooltip.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointment_tooltip.test.ts @@ -2,22 +2,101 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; -import { createScheduler } from './__mock__/create_scheduler'; +import type Scheduler from '../m_scheduler'; +import { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; - -describe('Appointment tooltip behavior', () => { +import type { SchedulerModel } from './__mock__/model/scheduler'; + +const getDataSource = (): object[] => [ + { + text: 'Apt1', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, + { + text: 'Inside Collector Apt1', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, + { + text: 'Inside Collector Apt2', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, + { + text: 'Inside Collector Recurring Apt3', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + recurrenceRule: 'FREQ=YEARLY', + }, + { + text: 'Inside Collector Apt4', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, + { + text: 'Recurring Apt2', + startDate: new Date(2017, 4, 23, 9, 30), + endDate: new Date(2017, 4, 23, 10, 30), + recurrenceRule: 'FREQ=YEARLY', + }, +]; + +const pressDeleteKeyOnTooltipItem = (POM: SchedulerModel, itemIndex: number): void => { + const scrollableContent = POM.tooltip.getScrollableContent(); + + scrollableContent?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + for (let i = 0; i < itemIndex; i += 1) { + scrollableContent?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + } + + scrollableContent?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); +}; + +describe.each([ + 'desktop', + 'mobile', +])('Appointment tooltip %s', (platform) => { beforeEach(() => { fx.off = true; - setupSchedulerTestEnvironment(); + setupSchedulerTestEnvironment({ + classRects: { + // Note: set sizes, so navigation in tooltip list would work + 'dx-list-item': { width: 100, height: 50 }, + }, + }); }); afterEach(() => { fx.off = false; jest.useRealTimers(); + const $scheduler = $('.dx-scheduler'); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); document.body.innerHTML = ''; }); + const createScheduler = (config: any): Promise<{ + container: HTMLDivElement; + scheduler: Scheduler; + POM: SchedulerModel; + keydown: (element: Element, key: string) => void; + }> => baseCreateScheduler({ + adaptivityEnabled: platform === 'mobile', + views: [{ type: 'month', maxAppointmentsPerCell: 1 }], + currentView: 'month', + currentDate: new Date(2017, 4, 1), + editing: true, + height: 600, + width: 1000, + ...config, + }); + describe('Delete button', () => { it('delete button in tooltip should not be focusable using tab', async () => { const { POM } = await createScheduler({ @@ -33,10 +112,6 @@ describe('Appointment tooltip behavior', () => { endDate: new Date(2017, 4, 22, 10, 30), }, ], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - height: 600, }); POM.getCollectorButton().click(); @@ -46,200 +121,330 @@ describe('Appointment tooltip behavior', () => { }); describe('Deleting appointments', () => { - it('should delete appointment by Delete key when focused in tooltip from collector', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - ]; - - const { scheduler, POM } = await createScheduler({ - dataSource: [...data], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - height: 600, + describe.each([ + 'delete key', + 'click', + ])('Try delete by %s', (method) => { + it('should delete appointment', async () => { + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); + + jest.useFakeTimers(); + POM.getAppointments()[0].element.click(); + jest.runAllTimers(); + + if (method === 'delete key') { + pressDeleteKeyOnTooltipItem(POM, 0); + } else { + POM.tooltip.getDeleteButton().click(); + } + + expect(POM.tooltip.isVisible()).toBe(false); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Apt1' }) }), + ); }); - POM.getCollectorButton().click(); + it('should not delete appointment by Delete key when editing.allowDeleting=false', async () => { + const onAppointmentDeleted = jest.fn(); - const tooltipScrollableContent = POM.tooltip.getScrollableContent(); - tooltipScrollableContent?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - tooltipScrollableContent?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); + const { POM } = await createScheduler({ + dataSource: getDataSource(), + editing: { allowDeleting: false }, + onAppointmentDeleted, + }); - expect(POM.tooltip.isVisible()).toBe(false); - expect((scheduler as any).getDataSource().items()).toEqual([data[0]]); - }); + jest.useFakeTimers(); + POM.getAppointments()[0].element.click(); + jest.runAllTimers(); - it('should delete appointment on delete button click in tooltip', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - ]; - - const { POM, scheduler } = await createScheduler({ - dataSource: [...data], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - height: 600, + if (method === 'delete key') { + pressDeleteKeyOnTooltipItem(POM, 0); + } else { + expect(POM.tooltip.getDeleteButtons().length).toBe(0); + } + + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).not.toHaveBeenCalled(); }); - POM.getCollectorButton().click(); - POM.tooltip.getDeleteButton().click(); + it('should not delete disabled appointment', async () => { + const onAppointmentDeleted = jest.fn(); - expect(POM.tooltip.isVisible()).toBe(false); - expect((scheduler as any).getDataSource().items()).toEqual([data[0]]); + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Apt1', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, { + text: 'Apt2', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + }, { + text: 'Apt3', + startDate: new Date(2017, 4, 21, 9, 30), + endDate: new Date(2017, 4, 21, 10, 30), + disabled: true, + }], + onAppointmentDeleted, + }); + + POM.getCollectorButton().click(); + + if (method === 'delete key') { + pressDeleteKeyOnTooltipItem(POM, 1); + } else { + expect(POM.tooltip.getAppointmentItems().length).toBe(2); + expect(POM.tooltip.getDeleteButtons().length).toBe(1); + } + + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).not.toHaveBeenCalled(); + }); }); - it('should not delete appointment by Delete key when editing.allowDeleting=false', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - ]; - - const { POM, scheduler } = await createScheduler({ - dataSource: [...data], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - height: 600, - editing: { - allowDeleting: false, - }, + describe('Single appointment deleting', () => { + it('should delete single occurrence on clicking \'Delete appointment\'', async () => { + const onAppointmentDeleted = jest.fn(); + const onAppointmentUpdated = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + onAppointmentUpdated, + }); + + jest.useFakeTimers(); + POM.getAppointment('Recurring Apt2').element?.click(); + jest.runAllTimers(); + + POM.tooltip.getDeleteButton().click(); + POM.popup.deleteAppointmentButton.click(); + + expect(POM.tooltip.isVisible()).toBe(false); + expect(onAppointmentDeleted).not.toHaveBeenCalled(); + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Recurring Apt2' }) }), + ); }); - POM.getCollectorButton().click(); + it('should delete all occurrences on clicking \'Delete series\'', async () => { + const onAppointmentDeleted = jest.fn(); - const tooltipScrollableContent = POM.tooltip.getScrollableContent(); - tooltipScrollableContent?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - tooltipScrollableContent?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); - expect((scheduler as any).getDataSource().items()).toEqual([...data]); - }); + jest.useFakeTimers(); + POM.getAppointment('Recurring Apt2').element?.click(); + jest.runAllTimers(); + + POM.tooltip.getDeleteButton().click(); + POM.popup.deleteSeriesButton.click(); - it('should not delete disabled appointment by Delete key when focused in tooltip from collector', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - disabled: true, - }, - ]; - - const { POM, scheduler } = await createScheduler({ - dataSource: [...data], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - height: 600, + expect(POM.tooltip.isVisible()).toBe(false); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Recurring Apt2' }) }), + ); }); - POM.getCollectorButton().click(); + it('should delete appointment on delete button click', async () => { + const onAppointmentDeleted = jest.fn(); - const tooltipScrollableContent = POM.tooltip.getScrollableContent(); - tooltipScrollableContent?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - tooltipScrollableContent?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); - expect((scheduler as any).getDataSource().items()).toEqual([...data]); + jest.useFakeTimers(); + POM.getAppointments()[0].element.click(); + jest.runAllTimers(); + + POM.tooltip.getDeleteButton().click(); + + expect(POM.tooltip.isVisible()).toBe(false); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Apt1' }) }), + ); + }); }); - it('should delete single occurrence on delete button click and clicking \'Delete appointment\'', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - recurrenceRule: 'FREQ=DAILY', - }, - ]; - - const { POM, scheduler } = await createScheduler({ - dataSource: [{ ...data[0] }, { ...data[1] }], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - editing: true, - height: 600, + describe('Deleting from collector tooltip', () => { + it('should delete focused appointment by Delete key', async () => { + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); + + POM.getCollectorButton().click(); + pressDeleteKeyOnTooltipItem(POM, 1); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt2' }) }), + ); }); - POM.getCollectorButton().click(); - POM.tooltip.getDeleteButton(0).click(); - POM.popup.deleteAppointmentButton.click(); + it('should delete specific appointment on delete button click', async () => { + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); - const items = (scheduler as any).getDataSource().items(); + POM.getCollectorButton().click(); + pressDeleteKeyOnTooltipItem(POM, 1); - expect(items).toEqual([ - data[0], - expect.objectContaining(data[1]), - ]); + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt2' }) }), + ); + }); - expect(items[1].recurrenceException).toContain('20170522'); + it('should delete single occurrence on clicking \'Delete appointment\'', async () => { + const onAppointmentUpdated = jest.fn(); + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + onAppointmentUpdated, + }); + + POM.getCollectorButton().click(); + POM.tooltip.getDeleteButton(2).click(); + POM.popup.deleteAppointmentButton.click(); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).not.toHaveBeenCalled(); + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Recurring Apt3' }) }), + ); + }); + + it('should not close tooltip if there are still appointments after deleting one of them', async () => { + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); + + POM.getCollectorButton().click(); + POM.tooltip.getDeleteButton(0).click(); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(1); + expect(onAppointmentDeleted).toHaveBeenCalledWith( + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt1' }) }), + ); + }); + + it('should close tooltip after deleting all appointments in the tooltip', async () => { + const onAppointmentDeleted = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: getDataSource(), + onAppointmentDeleted, + }); + + POM.getCollectorButton().click(); + POM.tooltip.getDeleteButton(3).click(); + POM.tooltip.getDeleteButton(2).click(); + POM.popup.deleteSeriesButton.click(); + POM.tooltip.getDeleteButton(1).click(); + POM.tooltip.getDeleteButton(0).click(); + + expect(POM.tooltip.isVisible()).toBe(false); + expect(onAppointmentDeleted).toHaveBeenCalledTimes(4); + expect(onAppointmentDeleted).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt4' }) }), + ); + expect(onAppointmentDeleted).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Recurring Apt3' }) }), + ); + expect(onAppointmentDeleted).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt2' }) }), + ); + expect(onAppointmentDeleted).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Inside Collector Apt1' }) }), + ); + }); }); + }); + + describe('State', () => { + if (platform === 'desktop') { + it('should have correct target after appointment was added before the current target', async () => { + const { scheduler, POM } = await createScheduler({ + dataSource: getDataSource(), + }); + + POM.getCollectorButton().click(); + + const initialTarget = POM.tooltip.target; - it('should delete all occurrences on delete and clicking \'Delete series\'', async () => { - const data = [ - { - text: 'Apt1', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - }, - { - text: 'Apt2', - startDate: new Date(2017, 4, 22, 9, 30), - endDate: new Date(2017, 4, 22, 10, 30), - recurrenceRule: 'FREQ=DAILY', - }, - ]; - - const { POM, scheduler } = await createScheduler({ - dataSource: [{ ...data[0] }, { ...data[1] }], - views: [{ type: 'month', maxAppointmentsPerCell: 1 }], - currentView: 'month', - currentDate: new Date(2017, 4, 22), - editing: true, - height: 600, + scheduler.addAppointment({ + text: 'New Apt', + startDate: new Date(2017, 4, 20, 9, 30), + endDate: new Date(2017, 4, 20, 10, 30), + }); + + await new Promise(process.nextTick); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(POM.tooltip.target).toBe(initialTarget); + }); + + it('should have correct target after appointment was deleted from tooltip', async () => { + const { POM } = await createScheduler({ + dataSource: getDataSource(), + }); + + POM.getCollectorButton().click(); + pressDeleteKeyOnTooltipItem(POM, 0); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(POM.tooltip.target).toBe(POM.getCollectorButton()); + }); + } + + it('should not rerender tooltip appointments when deleting appointment from tooltip', async () => { + const { POM } = await createScheduler({ + dataSource: getDataSource(), }); POM.getCollectorButton().click(); - POM.tooltip.getDeleteButton(0).click(); - POM.popup.deleteSeriesButton.click(); - expect((scheduler as any).getDataSource().items()).toEqual([data[0]]); + const item1 = POM.tooltip.getAppointmentItem(1); + const item2 = POM.tooltip.getAppointmentItem(2); + const item3 = POM.tooltip.getAppointmentItem(3); + + pressDeleteKeyOnTooltipItem(POM, 0); + + expect(POM.tooltip.isVisible()).toBe(true); + expect(POM.tooltip.getAppointmentItem(0)).toBe(item1); + expect(POM.tooltip.getAppointmentItem(1)).toBe(item2); + expect(POM.tooltip.getAppointmentItem(2)).toBe(item3); }); }); }); 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 19a5d2c4fb1b..5c9b817bbedc 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -65,6 +65,7 @@ interface ViewModelDiff { element?: dxElementWrapper; needToAdd?: true; needToRemove?: true; + needToUpdateItems?: true; } class SchedulerAppointments extends CollectionWidget { @@ -323,19 +324,34 @@ class SchedulerAppointments extends CollectionWidget { } if (item.needToRemove) { - item.element?.detach(); + this.hideTooltipIfNeed(item.element); item.element?.remove(); return; } + const container = item.item.allDay + ? $allDayFragment + : $commonFragment; + if (item.needToAdd) { - const container = item.item.allDay - ? $allDayFragment - : $commonFragment; this._renderItem(index, item.item, container); return; } + if (item.needToUpdateItems) { + const $oldElement = item.element as dxElementWrapper; + const $newElement = this._renderItem(index, item.item, container); + + this.updateTooltipIfNeed( + $oldElement, + $newElement, + item.item as AppointmentCollectorViewModel, + ); + + $oldElement?.remove(); + return; + } + if (item.element) { item.element.data(APPOINTMENT_SETTINGS_KEY, item.item); this.$itemBySortedIndex[item.item.sortedIndex] = item.element; @@ -344,6 +360,36 @@ class SchedulerAppointments extends CollectionWidget { }); } + private hideTooltipIfNeed($element?: dxElementWrapper): void { + const { appointmentTooltip } = this.option(); + + if (appointmentTooltip.isShownForTarget($element)) { + appointmentTooltip.hide(); + } + } + + private updateTooltipIfNeed( + $oldElement: dxElementWrapper, + $newElement: dxElementWrapper, + collectorViewModel: AppointmentCollectorViewModel, + ): void { + const { appointmentTooltip } = this.option(); + + if (!appointmentTooltip.isShownForTarget($oldElement)) { + return; + } + + if (collectorViewModel.items.length === 0) { + appointmentTooltip.hide(); + return; + } + + const dataList = this.getCompactAppointmentItems(collectorViewModel); + + appointmentTooltip.setTarget($newElement); + appointmentTooltip.setListItems(dataList); + } + _renderByFragments(renderFunction: ( $commonFragment: dxElementWrapper, $allDayFragment: dxElementWrapper, @@ -980,28 +1026,7 @@ class SchedulerAppointments extends CollectionWidget { $fragment: dxElementWrapper, appointment: AppointmentCollectorViewModel, ): dxElementWrapper { - const virtualItems = appointment.items; - const items: CompactAppointmentOptions['items'] = []; - virtualItems.forEach((item) => { - const appointmentConfig = { - itemData: item.itemData, - groupIndex: appointment.groupIndex, - groups: this.option('groups'), - }; - const resourceManager = this.getResourceManager(); - - items.push({ - appointment: item.itemData, - targetedAppointment: getTargetedAppointment( - item.itemData, - item, - this.dataAccessors, - resourceManager, - ), - color: resourceManager.getAppointmentColor(appointmentConfig), - settings: item, - }); - }); + const compactAppointmentItems = this.getCompactAppointmentItems(appointment); const $item = this.invoke('renderCompactAppointments', { $container: $fragment, @@ -1009,8 +1034,8 @@ class SchedulerAppointments extends CollectionWidget { top: appointment.top, left: appointment.left, }, - items, - buttonColor: items[0].color, + items: compactAppointmentItems, + buttonColor: compactAppointmentItems[0].color, sortedIndex: appointment.sortedIndex, width: appointment.width, height: appointment.height, @@ -1023,6 +1048,34 @@ class SchedulerAppointments extends CollectionWidget { return $item; } + private getCompactAppointmentItems( + appointment: AppointmentCollectorViewModel, + ): CompactAppointmentOptions['items'] { + const resourceManager = this.getResourceManager(); + + const result = appointment.items.map((item) => { + const appointmentConfig = { + itemData: item.itemData, + groupIndex: appointment.groupIndex, + groups: this.option('groups'), + }; + + return { + appointment: item.itemData, + targetedAppointment: getTargetedAppointment( + item.itemData, + item, + this.dataAccessors, + resourceManager, + ), + color: resourceManager.getAppointmentColor(appointmentConfig), + settings: item, + }; + }); + + return result; + } + moveAppointmentBack(dragEvent?) { const $appointment = this._kbn.$focusTarget(); const size = this._initialSize; diff --git a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.test.ts b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.test.ts index cd4eaa0500a6..35c8991e6b35 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.test.ts @@ -2,24 +2,30 @@ import { describe, expect, it, } from '@jest/globals'; -import { getArraysDiff, isNeedToAdd, isNeedToRemove } from './get_arrays_diff'; +import { + getArraysDiff, isNeedToAdd, isNeedToRemove, isNeedToUpdateItems, +} from './get_arrays_diff'; interface Obj { id: number; name: string } const compare = (a: Obj, b: Obj): boolean => a.id === b.id && a.name === b.name; +const noItemsLengthChange = (): boolean => true; const getOperations = (items: ReturnType>): string => items .map((item) => { if (isNeedToAdd(item)) { return '+'; } - return isNeedToRemove(item) ? '-' : '='; + if (isNeedToRemove(item)) { + return '-'; + } + return isNeedToUpdateItems(item) ? '~' : '='; }) .join(''); describe('getArraysDiff', () => { it('should process both empty arrays', () => { - const diff = getArraysDiff([], [], compare); + const diff = getArraysDiff([], [], compare, compare, noItemsLengthChange); expect(diff).toEqual([]); }); @@ -35,7 +41,7 @@ describe('getArraysDiff', () => { { id: 3, name: 'C' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('==='); expect(diff).toEqual([ @@ -52,7 +58,7 @@ describe('getArraysDiff', () => { { id: 11, name: 'Y' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('++'); expect(diff).toEqual([ @@ -68,7 +74,7 @@ describe('getArraysDiff', () => { ]; const b: Obj[] = []; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('--'); expect(diff).toEqual([ @@ -89,7 +95,7 @@ describe('getArraysDiff', () => { { id: 4, name: 'D' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('=+-='); expect(diff).toEqual([ @@ -112,7 +118,7 @@ describe('getArraysDiff', () => { { id: 4, name: 'D' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('=+-='); expect(diff).toEqual([ @@ -137,7 +143,7 @@ describe('getArraysDiff', () => { { id: 3, name: 'C' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('+===-'); expect(diff).toEqual([ @@ -163,7 +169,7 @@ describe('getArraysDiff', () => { { id: 3, name: 'C' }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('+=+-=-'); expect(diff).toEqual([ @@ -190,7 +196,7 @@ describe('getArraysDiff', () => { { id: 3, name: 'C', extra: 40 }, ]; - const diff = getArraysDiff(a, b, compare); + const diff = getArraysDiff(a, b, compare, compare, noItemsLengthChange); expect(getOperations(diff)).toBe('+=+-=-'); expect(diff).toEqual([ @@ -203,3 +209,67 @@ describe('getArraysDiff', () => { ]); }); }); + +describe('getArraysDiff needToUpdateItems', () => { + interface CollectorObj { id: number; pos: number; count: number } + + const matchById = (a: CollectorObj, b: CollectorObj): boolean => a.id === b.id; + const equalByPos = (a: CollectorObj, b: CollectorObj): boolean => a.pos === b.pos; + const equalByCount = (a: CollectorObj, b: CollectorObj): boolean => a.count === b.count; + + it('should mark as needToUpdateItems when match and equal but itemsLengthEqual is false', () => { + const a: CollectorObj[] = [{ id: 1, pos: 0, count: 2 }]; + const b: CollectorObj[] = [{ id: 1, pos: 0, count: 3 }]; + + const diff = getArraysDiff(a, b, matchById, equalByPos, equalByCount); + + expect(getOperations(diff)).toBe('~'); + expect(diff).toEqual([{ item: b[0], needToUpdateItems: true }]); + }); + + it('should not mark as needToUpdateItems when match, equal and itemsLengthEqual are all true', () => { + const a: CollectorObj[] = [{ id: 1, pos: 0, count: 2 }]; + const b: CollectorObj[] = [{ id: 1, pos: 0, count: 2 }]; + + const diff = getArraysDiff(a, b, matchById, equalByPos, equalByCount); + + expect(getOperations(diff)).toBe('='); + expect(diff).toEqual([{ item: b[0] }]); + }); + + it('should produce remove+add when match is true but equal is false', () => { + const a: CollectorObj[] = [{ id: 1, pos: 0, count: 2 }]; + const b: CollectorObj[] = [{ id: 1, pos: 5, count: 2 }]; + + const diff = getArraysDiff(a, b, matchById, equalByPos, equalByCount); + + expect(getOperations(diff)).toBe('+-'); + expect(diff).toEqual([ + { item: b[0], needToAdd: true }, + { item: a[0], needToRemove: true }, + ]); + }); + + it('should handle mix of needToUpdateItems, no change, add and remove', () => { + const a: CollectorObj[] = [ + { id: 1, pos: 0, count: 2 }, + { id: 2, pos: 10, count: 1 }, + { id: 3, pos: 20, count: 3 }, + ]; + const b: CollectorObj[] = [ + { id: 1, pos: 0, count: 4 }, + { id: 2, pos: 10, count: 1 }, + { id: 4, pos: 30, count: 1 }, + ]; + + const diff = getArraysDiff(a, b, matchById, equalByPos, equalByCount); + + expect(getOperations(diff)).toBe('~=+-'); + expect(diff).toEqual([ + { item: b[0], needToUpdateItems: true }, + { item: b[1] }, + { item: b[2], needToAdd: true }, + { item: a[2], needToRemove: true }, + ]); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.ts b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.ts index 10097cbc20fe..31b91e4a6056 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_arrays_diff.ts @@ -1,10 +1,12 @@ interface NoChanges { item: A } interface ToRemove { item: A; needToRemove: true } interface ToAdd { item: B; needToAdd: true } +interface ToUpdateItems { item: B; needToUpdateItems: true } export type DiffItem = | NoChanges | ToRemove - | ToAdd; + | ToAdd + | ToUpdateItems; export const isNeedToRemove = ( item: DiffItem, @@ -14,10 +16,16 @@ export const isNeedToAdd = ( item: DiffItem, ): item is ToAdd => (item as ToAdd).needToAdd; +export const isNeedToUpdateItems = ( + item: DiffItem, +): item is ToUpdateItems => (item as ToUpdateItems).needToUpdateItems; + export function getArraysDiff( a: A[], b: B[], + match: (x: A, y: B) => boolean, equal: (x: A, y: B) => boolean, + itemsLengthEqual: (x: A, y: B) => boolean, ): DiffItem[] { const n = a.length; const m = b.length; @@ -27,7 +35,7 @@ export function getArraysDiff( for (let i = 1; i <= n; i += 1) { const ai = a[i - 1]; for (let j = 1; j <= m; j += 1) { - dp[i][j] = equal(ai, b[j - 1]) + dp[i][j] = match(ai, b[j - 1]) ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } @@ -38,15 +46,29 @@ export function getArraysDiff( let j = m; while (i > 0 && j > 0) { - if (equal(a[i - 1], b[j - 1])) { - result.push({ item: b[j - 1] }); + const ai = a[i - 1]; + const bj = b[j - 1]; + + if (match(ai, bj)) { + if (equal(ai, bj)) { + // eslint-disable-next-line max-depth + if (!itemsLengthEqual(ai, bj)) { + result.push({ item: bj, needToUpdateItems: true }); + } else { + result.push({ item: bj }); + } + } else { + result.push({ item: ai, needToRemove: true }); + result.push({ item: bj, needToAdd: true }); + } + i -= 1; j -= 1; } else if (dp[i - 1][j] >= dp[i][j - 1]) { - result.push({ item: a[i - 1], needToRemove: true }); + result.push({ item: ai, needToRemove: true }); i -= 1; } else { - result.push({ item: b[j - 1], needToAdd: true }); + result.push({ item: bj, needToAdd: true }); j -= 1; } } diff --git a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_view_model_diff.ts b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_view_model_diff.ts index 149d706be122..6518d9a0bced 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/utils/get_view_model_diff.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/utils/get_view_model_diff.ts @@ -19,7 +19,6 @@ const getObjectToCompare = ( groupIndex: item.groupIndex, top: item.top, left: item.left, - items: item.items.length, }; } @@ -39,6 +38,16 @@ const getObjectToCompare = ( }; }; +const getItemsLengthToCompare = (item: AppointmentViewModelPlain): object => { + if ('items' in item) { + return { + items: item.items.length, + }; + } + + return {}; +}; + const isDataChanged = ( data: SafeAppointment, appointmentDataSource: AppointmentDataSource, @@ -50,19 +59,39 @@ const isDataChanged = ( .some((item) => data[item.key] === item.value); }; -const compareViewModel = (appointmentDataSource: AppointmentDataSource) => ( - viewModelOld: AppointmentViewModelPlain, - viewModelNext: AppointmentViewModelPlain, -): boolean => viewModelOld.itemData === viewModelNext.itemData - && !isDataChanged(viewModelNext.itemData, appointmentDataSource) - && equalByValue(getObjectToCompare(viewModelOld), getObjectToCompare(viewModelNext)); - export const getViewModelDiff = ( viewModelOld: AppointmentViewModelPlain[], viewModelNext: AppointmentViewModelPlain[], appointmentDataSource: AppointmentDataSource, -): DiffItem[] => getArraysDiff( - viewModelOld, - viewModelNext, - compareViewModel(appointmentDataSource), -); +): DiffItem[] => { + const match = ( + a: AppointmentViewModelPlain, + b: AppointmentViewModelPlain, + ): boolean => { + if ('items' in a && 'items' in b) { + return true; + } + + return a.itemData === b.itemData && !isDataChanged(a.itemData, appointmentDataSource); + }; + + const equal = ( + a: AppointmentViewModelPlain, + b: AppointmentViewModelPlain, + ): boolean => equalByValue(getObjectToCompare(a), getObjectToCompare(b)); + + const itemsLengthEqual = ( + a: AppointmentViewModelPlain, + b: AppointmentViewModelPlain, + ): boolean => equalByValue(getItemsLengthToCompare(a), getItemsLengthToCompare(b)); + + const result = getArraysDiff( + viewModelOld, + viewModelNext, + match, + equal, + itemsLengthEqual, + ); + + return result; +}; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 3931eb33c498..b3b7a7693738 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1061,6 +1061,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._layoutManager = new AppointmentLayoutManager(this); + this.appointmentTooltip = new (this.option('adaptivityEnabled') + ? MobileTooltipStrategy + : DesktopTooltipStrategy)(this.getAppointmentTooltipOptions()); + if (this.option('_newAppointments')) { const appointmentsConfig: Partial = { tabIndex: this.option('tabIndex'), @@ -1092,10 +1096,6 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._appointments.option('itemTemplate', this.getAppointmentTemplate('appointmentTemplate')); } - this.appointmentTooltip = new (this.option('adaptivityEnabled') - ? MobileTooltipStrategy - : DesktopTooltipStrategy)(this.getAppointmentTooltipOptions()); - this.createAppointmentPopupForm(); // @ts-expect-error @@ -1322,6 +1322,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { getAppointmentDataSource: () => this.appointmentDataSource, getSortedAppointments: () => this._layoutManager.sortedItems, scrollTo: this.scrollTo.bind(this), + appointmentTooltip: this.appointmentTooltip, dataAccessors: this._dataAccessors, notifyScheduler: this.notifyScheduler, onItemRendered: this.getAppointmentRenderedAction(), @@ -2092,7 +2093,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._createActionByOption('onAppointmentTooltipShowing')(arg); - if (this.appointmentTooltip.isAlreadyShown(target)) { + if (this.appointmentTooltip.isShownForTarget(target)) { this.hideAppointmentTooltip(); } else { this.processActionResult(arg, (canceled) => { diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_desktop_tooltip_strategy.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_desktop_tooltip_strategy.ts index fda27637da26..39de4b390f81 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_desktop_tooltip_strategy.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_desktop_tooltip_strategy.ts @@ -40,11 +40,11 @@ export class DesktopTooltipStrategy extends TooltipStrategyBase { return result; } - protected override createTooltip(target, dataList) { + protected override createTooltip(dataList) { const tooltipElement = this.createTooltipElement(APPOINTMENT_TOOLTIP_WRAPPER_CLASS); const tooltip = this._options.createComponent(tooltipElement, Tooltip, { - target, + target: this.$target, maxHeight: MAX_TOOLTIP_HEIGHT, rtlEnabled: this.extraOptions.rtlEnabled, onShown: this.onShown.bind(this), diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_mobile_tooltip_strategy.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_mobile_tooltip_strategy.ts index 4c0d5b7017ac..cd6199c15388 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_mobile_tooltip_strategy.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_mobile_tooltip_strategy.ts @@ -64,7 +64,7 @@ const createTabletDeviceConfig = (listHeight) => { }; export class MobileTooltipStrategy extends TooltipStrategyBase { - protected override shouldUseTarget() { + protected override isDesktop() { return false; } @@ -91,7 +91,7 @@ export class MobileTooltipStrategy extends TooltipStrategyBase { this.setTooltipConfig(); } - protected override createTooltip(target, dataList) { + protected override createTooltip(dataList) { const element = this.createTooltipElement(CLASS.slidePanel); return this._options.createComponent(element, Overlay, { diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_tooltip_strategy_base.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_tooltip_strategy_base.ts index 2b0b2822527b..f039793c2f0b 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_tooltip_strategy_base.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/m_tooltip_strategy_base.ts @@ -1,3 +1,4 @@ +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { FunctionTemplate } from '@js/core/templates/function_template'; import { isRenderer } from '@js/core/utils/type'; @@ -5,6 +6,8 @@ import Button from '@js/ui/button'; import { createPromise } from '@ts/core/utils/promise'; import List from '@ts/ui/list/list.edit'; +import type { CompactAppointmentOptions } from '../types'; + const TOOLTIP_APPOINTMENT_ITEM = 'dx-tooltip-appointment-item'; const TOOLTIP_APPOINTMENT_ITEM_CONTENT = `${TOOLTIP_APPOINTMENT_ITEM}-content`; const TOOLTIP_APPOINTMENT_ITEM_CONTENT_SUBJECT = `${TOOLTIP_APPOINTMENT_ITEM}-content-subject`; @@ -29,34 +32,56 @@ export class TooltipStrategyBase { protected list: any; + protected $target: dxElementWrapper | null = null; + constructor(options) { this.tooltip = null; this._options = options; this.extraOptions = null; } - show(target, dataList, extraOptions) { - if (this.canShowTooltip(dataList)) { + show(target: dxElementWrapper, dataList, extraOptions) { + if (dataList.length) { this.hide(); + this.$target = target; this.extraOptions = extraOptions; - this.showCore(target, dataList); + this.showCore(dataList); + } + } + + public setTarget($target: dxElementWrapper): void { + this.$target = $target; + + if (this.isDesktop()) { + const originalAnimationValue = this.tooltip.option('animation'); + + this.tooltip.option('animation', null); + this.tooltip.option('target', $target); + this.tooltip.option('animation', originalAnimationValue); } } - private showCore(target, dataList) { - const describedByValue = isRenderer(target) && target.attr('aria-describedby') as string; + public setListItems(dataList: CompactAppointmentOptions['items']): void { + this.list.option('dataSource', dataList); + } + + private showCore(dataList) { + const describedByValue = isRenderer(this.$target) && this.$target?.attr('aria-describedby') as string; if (!this.tooltip) { - this.tooltip = this.createTooltip(target, dataList); + this.tooltip = this.createTooltip(dataList); } else { - this.shouldUseTarget() && this.tooltip.option('target', target); + if (this.isDesktop()) { + this.tooltip.option('target', this.$target); + } + this.list.option('dataSource', dataList); } this.prepareBeforeVisibleChanged(dataList); this.tooltip.option('visible', true); - describedByValue && target.attr('aria-describedby', describedByValue); + describedByValue && this.$target?.attr('aria-describedby', describedByValue); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -92,18 +117,18 @@ export class TooltipStrategyBase { } if (this.isDeletingAllowed(appointment)) { - this.hide(); this._options.checkAndDeleteAppointment(appointment, targetedAppointment); } }); }; } - isAlreadyShown(target) { - if (this.tooltip && this.tooltip.option('visible')) { - return this.tooltip.option('target')[0] === target[0]; + isShownForTarget($target: dxElementWrapper): boolean { + if (!this.tooltip?.option('visible')) { + return false; } - return undefined; + + return $target.get(0) === this.$target?.get(0); } protected onShown() { @@ -119,19 +144,12 @@ export class TooltipStrategyBase { } } - protected shouldUseTarget() { + protected isDesktop(): boolean { return true; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected createTooltip(target, dataList) { - } - - private canShowTooltip(dataList) { - if (!dataList.length) { - return false; - } - return true; + protected createTooltip(dataList) { } protected createListOption(dataList) { @@ -142,6 +160,7 @@ export class TooltipStrategyBase { onItemContextMenu: this.onListItemContextMenu.bind(this), itemTemplate: (item, index) => this.renderTemplate(item.appointment, item.targetedAppointment, index, item.color), pageLoadMode: 'scrollBottom', + repaintChangesOnly: true, }; } @@ -248,7 +267,6 @@ export class TooltipStrategyBase { stylingMode: 'text', tabIndex: -1, onClick: (e) => { - this.hide(); e.event.stopPropagation(); this._options.checkAndDeleteAppointment(appointment, targetedAppointment); }, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/common.methods.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/common.methods.tests.js index 04beb0447b0a..fd3d36810fd9 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/common.methods.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/common.methods.tests.js @@ -636,7 +636,7 @@ QUnit.module('Methods', { QUnit.test('showAppointmentTooltipCore, should call show tooltip', async function(assert) { const scheduler = await createInstance({}); - scheduler.instance.appointmentTooltip.isAlreadyShown = sinon.stub().returns(false); + scheduler.instance.appointmentTooltip.isShownForTarget = sinon.stub().returns(false); scheduler.instance.appointmentTooltip.show = sinon.stub(); scheduler.instance.appointmentTooltip.hide = sinon.stub(); scheduler.instance.showAppointmentTooltipCore('target', [], 'options'); @@ -647,7 +647,7 @@ QUnit.module('Methods', { QUnit.test('showAppointmentTooltipCore, should call hide tooltip', async function(assert) { const scheduler = await createInstance({}); - scheduler.instance.appointmentTooltip.isAlreadyShown = sinon.stub().returns(true); + scheduler.instance.appointmentTooltip.isShownForTarget = sinon.stub().returns(true); scheduler.instance.appointmentTooltip.show = sinon.stub(); scheduler.instance.appointmentTooltip.hide = sinon.stub(); scheduler.instance.showAppointmentTooltipCore('target', [], 'options'); @@ -658,7 +658,7 @@ QUnit.module('Methods', { QUnit.test('showAppointmentTooltip, should call show tooltip', async function(assert) { const scheduler = await createInstance({}); - scheduler.instance.appointmentTooltip.isAlreadyShown = sinon.stub().returns(false); + scheduler.instance.appointmentTooltip.isShownForTarget = sinon.stub().returns(false); scheduler.instance.appointmentTooltip.show = sinon.stub(); scheduler.instance.appointmentTooltip.hide = sinon.stub(); scheduler.instance.showAppointmentTooltip('appointmentData', 'target', 'currentAppointmentData'); @@ -669,7 +669,7 @@ QUnit.module('Methods', { QUnit.test('showAppointmentTooltip, should call hide tooltip', async function(assert) { const scheduler = await createInstance({}); - scheduler.instance.appointmentTooltip.isAlreadyShown = sinon.stub().returns(true); + scheduler.instance.appointmentTooltip.isShownForTarget = sinon.stub().returns(true); scheduler.instance.appointmentTooltip.show = sinon.stub(); scheduler.instance.appointmentTooltip.hide = sinon.stub(); scheduler.instance.showAppointmentTooltip('appointmentData', 'target', 'currentAppointmentData'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js index 5f18c3116e4d..85dd1df0a9f1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js @@ -116,8 +116,9 @@ QUnit.test('contentTemplate passed to createComponent should work correct', asyn assert.equal(stubCreateComponent.getCall(1).args[0][0].nodeName, 'DIV'); assert.equal(stubCreateComponent.getCall(1).args[1], List); - assert.equal(Object.keys(stubCreateComponent.getCall(1).args[2]).length, 7); + assert.equal(Object.keys(stubCreateComponent.getCall(1).args[2]).length, 8); assert.equal(stubCreateComponent.getCall(1).args[2].dataSource, dataList); + assert.equal(stubCreateComponent.getCall(1).args[2].repaintChangesOnly, true); assert.equal(stubCreateComponent.getCall(1).args[2].showScrollbar, 'onHover'); assert.ok(stubCreateComponent.getCall(1).args[2].onContentReady); assert.ok(stubCreateComponent.getCall(1).args[2].onItemClick); @@ -274,7 +275,6 @@ QUnit.test('itemTemplate passed to createComponent should work correct', async f stubComponent.option.reset(); const e = { event: { stopPropagation: sinon.spy() } }; stubCreateComponent.getCall(2).args[2].onClick(e); - assert.deepEqual(stubComponent.option.getCall(0).args, ['visible', false], 'tooltip is hide'); assert.ok(e.event.stopPropagation.called); assert.deepEqual(stubCheckAndDeleteAppointment.getCall(0).args, [item.appointment, item.targetedAppointment]); }); @@ -303,17 +303,17 @@ QUnit.test('Delete button shouldn\'t created, appointment is disabled', async fu assert.equal(stubCreateComponent.getCall(2), undefined); }); -QUnit.test('isAlreadyShown method, tooltip is not created', async function(assert) { +QUnit.test('isShownForTarget method, tooltip is not created', async function(assert) { const tooltip = this.createSimpleTooltip(this.tooltipOptions); - const target = ['target']; + const target = $('
'); - assert.ok(!tooltip.isAlreadyShown(target), 'tooltip is not created and haven\'t data'); + assert.ok(!tooltip.isShownForTarget(target), 'tooltip is not created and haven\'t data'); }); -QUnit.test('isAlreadyShown method, tooltip is created and shown', async function(assert) { +QUnit.test('isShownForTarget method, tooltip is created and shown', async function(assert) { const tooltip = this.createSimpleTooltip(this.tooltipOptions); const dataList = [{ data: 'data1' }, { data: 'data2' }]; - const target = ['target']; + const target = $('
'); const callback = sinon.stub(); callback.withArgs('target').returns(target); @@ -323,14 +323,14 @@ QUnit.test('isAlreadyShown method, tooltip is created and shown', async function tooltip.show(target, dataList, this.extraOptions); stubCreateComponent.getCall(0).args[2].contentTemplate('
'); - assert.ok(tooltip.isAlreadyShown(target), 'tooltip is shown and have the same target'); - assert.ok(!tooltip.isAlreadyShown(['target_1']), 'tooltip is shown and have another target'); + assert.ok(tooltip.isShownForTarget(target), 'tooltip is shown and have the same target'); + assert.ok(!tooltip.isShownForTarget($('
')), 'tooltip is shown and have another target'); }); -QUnit.test('isAlreadyShown method, tooltip is hide', async function(assert) { +QUnit.test('isShownForTarget method, tooltip is hide', async function(assert) { const tooltip = this.createSimpleTooltip(this.tooltipOptions); const dataList = [{ data: 'data1' }, { data: 'data2' }]; - const target = ['target']; + const target = $('
'); const callback = sinon.stub(); callback.withArgs('target').returns(target); @@ -338,7 +338,7 @@ QUnit.test('isAlreadyShown method, tooltip is hide', async function(assert) { stubComponent.option = callback; tooltip.show(target, dataList, this.extraOptions); - assert.ok(!tooltip.isAlreadyShown(target), 'tooltip is hidden'); + assert.ok(!tooltip.isShownForTarget(target), 'tooltip is hidden'); }); QUnit.test('appointmentTooltipTemplate equal to "appointmentTooltipTemplate"', async function(assert) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.adaptivity.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.adaptivity.tests.js index 8437ac20fb84..c3959c92971e 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.adaptivity.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.adaptivity.tests.js @@ -5,9 +5,8 @@ import { initTestMarkup, isDesktopEnvironment, TOOLBAR_TOP_LOCATION, - TOOLBAR_BOTTOM_LOCATION } from '../../helpers/scheduler/helpers.js'; +} from '../../helpers/scheduler/helpers.js'; import { getSimpleDataArray } from '../../helpers/scheduler/data.js'; -import { waitAsync } from '../../helpers/scheduler/waitForAsync.js'; import resizeCallbacks from 'core/utils/resize_callbacks'; import devices from '__internal/core/m_devices'; import 'ui/switch'; @@ -86,7 +85,7 @@ module('Mobile tooltip', moduleConfig, () => { clock.restore(); }); - test('Tooltip should hide after execute actions', async function(assert) { + test('Tooltip visibility after actions', async function(assert) { const scheduler = await createInstance(); const initialDataCount = scheduler.instance.option('dataSource').length; @@ -105,7 +104,7 @@ module('Mobile tooltip', moduleConfig, () => { assert.ok(scheduler.tooltip.isVisible(), 'Tooltip should be visible after click on appointment'); scheduler.tooltip.clickOnDeleteButton(); - assert.notOk(scheduler.tooltip.isVisible(), 'Tooltip should be hide after click on remove button in tooltip'); + assert.ok(scheduler.tooltip.isVisible(), 'Tooltip should be visible after click on remove button in tooltip'); assert.equal(scheduler.instance.option('dataSource').length, initialDataCount - 1, 'Appointment should delete form dataSource after click on delete button in tooltip'); }); @@ -244,7 +243,6 @@ if(!isDesktopEnvironment()) { module('Appointment popup buttons', moduleConfig, () => { const SECTION_AFTER = 'after'; - const SECTION_BEFORE = 'before'; const DONE_BUTTON = 'done'; const CANCEL_BUTTON = 'cancel'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentTooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentTooltip.tests.js index 52af727e9ab8..f2696f84b3a2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentTooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentTooltip.tests.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import dateSerialization from 'core/utils/date_serialization'; import Tooltip from 'ui/tooltip'; import { hide } from '__internal/ui/tooltip/m_tooltip'; import resizeCallbacks from 'core/utils/resize_callbacks'; @@ -545,71 +544,6 @@ module('Integration: Appointment tooltip', moduleConfig, () => { assert.equal(scheduler.tooltip.getDateText(), 'February 9 11:00 AM - 12:00 PM', 'dates and time were displayed correctly'); }); - test('Click on tooltip-remove button should call scheduler.deleteAppointment and hide tooltip', async function(assert) { - const data = new DataSource({ - store: getSampleData() - }); - - const scheduler = await createScheduler({ currentDate: new Date(2015, 1, 9), dataSource: data }); - const stub = sinon.stub(scheduler.instance, 'processDeleteAppointment'); - - const clock = sinon.useFakeTimers(); - await scheduler.appointments.click(1, clock); - clock.restore(); - scheduler.tooltip.clickOnDeleteButton(); - - assert.deepEqual(stub.getCall(0).args[0], - { - startDate: new Date(2015, 1, 9, 11, 0), - endDate: new Date(2015, 1, 9, 12, 0), - text: 'Task 2' - }, - 'processDeleteAppointment has a correct arguments'); - - assert.notOk(scheduler.tooltip.isVisible(), 'tooltip was hidden'); - }); - - test('Click on tooltip-remove button should call scheduler.updateAppointment and hide tooltip, if recurrenceRuleExpr and recurrenceExceptionExpr is set', async function(assert) { - const scheduler = await createScheduler({ - currentDate: new Date(2018, 6, 30), - currentView: 'month', - views: ['month'], - recurrenceRuleExpr: 'SC_RecurrenceRule', - recurrenceExceptionExpr: 'SC_RecurrenceException', - recurrenceEditMode: 'occurrence', - dataSource: [{ - text: 'Meeting of Instructors', - startDate: new Date(2018, 6, 30, 10, 0), - endDate: new Date(2018, 6, 30, 11, 0), - SC_RecurrenceRule: 'FREQ=DAILY;COUNT=3', - SC_RecurrenceException: '20170626T100000Z' - } - ] - }); - const stub = sinon.stub(scheduler.instance, 'updateAppointmentCore'); - - const clock = sinon.useFakeTimers(); - await scheduler.appointments.click(1, clock); - clock.restore(); - scheduler.tooltip.clickOnDeleteButton(); - - const exceptionDate = new Date(2018, 6, 31, 10, 0, 0, 0); - const exceptionString = dateSerialization.serializeDate(exceptionDate, 'yyyyMMddTHHmmssZ'); - - assert.deepEqual(stub.getCall(0).args[1], - { - startDate: new Date(2018, 6, 30, 10, 0), - endDate: new Date(2018, 6, 30, 11, 0), - text: 'Meeting of Instructors', - SC_RecurrenceRule: 'FREQ=DAILY;COUNT=3', - SC_RecurrenceException: '20170626T100000Z,' + exceptionString - }, - 'updateAppointment has a right arguments'); - - assert.notOk(scheduler.tooltip.isVisible(), 'tooltip was hidden'); - - }); - test('Tooltip should appear if mouse is over arrow icon', async function(assert) { const endDate = new Date(2015, 9, 12); @@ -1206,9 +1140,7 @@ module('New common tooltip for compact and cell appointments', moduleConfig, () assert.equal(scheduler.tooltip.getItemCount(), 2, 'Count of items in tooltip should be equal 2'); scheduler.tooltip.clickOnDeleteButton(1); - assert.notOk(scheduler.tooltip.isVisible(), 'Tooltip shouldn\'t visible'); - - scheduler.appointments.compact.click(scheduler.appointments.compact.getButtonCount() - 1); + assert.ok(scheduler.tooltip.isVisible(), 'Tooltip should be visible'); assert.equal(scheduler.tooltip.getItemCount(), 1, 'Count of items in tooltip should be equal 1'); scheduler.tooltip.clickOnDeleteButton(); @@ -1408,7 +1340,7 @@ module('New common tooltip for compact and cell appointments', moduleConfig, () ]); await waitAsync(0); - scheduler.appointments.compact.click(); + assert.ok(scheduler.tooltip.isVisible(), 'Tooltip should be visible'); assert.equal(getItemCount(), 1, 'Tooltip should render 1 item'); assert.roughEqual(getItemElement().outerHeight(), getOverlayContentElement().outerHeight(), 10, 'Tooltip height should equals then list height'); });