Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { jest } from '@jest/globals';
import $ from '@js/core/renderer';
// eslint-disable-next-line devextreme-custom/no-deferred
import { Deferred } from '@js/core/utils/deferred';

import { mockTimeZoneCalculator } from '../../__mock__/timezone_calculator.mock';
import { AppointmentForm } from '../../appointment_popup/m_form';
import {
ACTION_TO_APPOINTMENT,
APPOINTMENT_POPUP_CLASS,
AppointmentPopup,
} from '../../appointment_popup/m_popup';
import {
AppointmentDataAccessor,
} from '../../utils/data_accessor/appointment_data_accessor';
import type { IFieldExpr } from '../../utils/data_accessor/types';
import {
ResourceManager,
} from '../../utils/resource_manager/resource_manager';
import { PopupModel } from './model/popup';

const DEFAULT_FIELDS: IFieldExpr = {
startDateExpr: 'startDate',
endDateExpr: 'endDate',
startDateTimeZoneExpr: 'startDateTimeZone',
endDateTimeZoneExpr: 'endDateTimeZone',
allDayExpr: 'allDay',
textExpr: 'text',
descriptionExpr: 'description',
recurrenceRuleExpr: 'recurrenceRule',
recurrenceExceptionExpr: 'recurrenceException',
disabledExpr: 'disabled',
visibleExpr: 'visible',
};

const DEFAULT_EDITING = {
allowAdding: true,
allowUpdating: true,
allowDeleting: true,
allowResizing: true,
allowDragging: true,
legacyForm: false,
};

const DEFAULT_APPOINTMENT = {
text: 'Test Appointment',
startDate: new Date(2021, 3, 26, 9, 30),
endDate: new Date(2021, 3, 26, 11, 0),
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedDeferred = (): any => {
// @ts-expect-error
// eslint-disable-next-line devextreme-custom/no-deferred
const d = new Deferred();
d.resolve();
return d.promise();
};

interface CreateAppointmentPopupOptions {
appointmentData?: Record<string, unknown>;
action?: number;
editing?: Record<string, unknown>;
firstDayOfWeek?: number;
startDayHour?: number;
onAppointmentFormOpening?: (...args: unknown[]) => void;
addAppointment?: jest.Mock;
updateAppointment?: jest.Mock;
}
Comment on lines +60 to +69
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The option types here are very generic (Record<string, unknown>, number), which reduces the value of these helpers and can hide breaking changes in the popup contract. Consider typing appointmentData as Partial<SafeAppointment> (or similar) and action as a union of ACTION_TO_APPOINTMENT values, and tightening editing to the scheduler editing config shape used by AppointmentPopup/AppointmentForm.

Copilot uses AI. Check for mistakes.

interface CreateAppointmentPopupResult {
container: HTMLDivElement;
popup: AppointmentPopup;
form: AppointmentForm;
POM: PopupModel;
callbacks: {
addAppointment: jest.Mock;
updateAppointment: jest.Mock;
focus: jest.Mock;
updateScrollPosition: jest.Mock;
};
dispose: () => void;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createComponent = (element: any, Component: any, opts: any): any => (
new Component($(element), opts)
);

const disposables: (() => void)[] = [];

export const disposeAppointmentPopups = (): void => {
disposables.forEach((fn) => fn());
disposables.length = 0;
document.body.innerHTML = '';
};

export const createAppointmentPopup = async (
options: CreateAppointmentPopupOptions = {},
): Promise<CreateAppointmentPopupResult> => {
const container = document.createElement('div');
document.body.appendChild(container);

const dataAccessors = new AppointmentDataAccessor(DEFAULT_FIELDS, false);
const resourceManager = new ResourceManager([]);
const timeZoneCalculator = mockTimeZoneCalculator;
const editing = { ...DEFAULT_EDITING, ...options.editing };

const addAppointment = options.addAppointment
?? jest.fn(resolvedDeferred);
const updateAppointment = options.updateAppointment
?? jest.fn(resolvedDeferred);
const focus = jest.fn();
const updateScrollPosition = jest.fn();

const formSchedulerProxy = {
getResourceById: (): Record<string, unknown> => (
resourceManager.resourceById
),
getDataAccessors: (): AppointmentDataAccessor => dataAccessors,
createComponent,
getEditingConfig: (): typeof editing => editing,
getResourceManager: (): ResourceManager => resourceManager,
getFirstDayOfWeek: (): number => options.firstDayOfWeek ?? 0,
getStartDayHour: (): number => options.startDayHour ?? 0,
getCalculatedEndDate: (startDate: Date): Date => {
const endDate = new Date(startDate);
endDate.setHours(endDate.getHours() + 1);
return endDate;
},
getTimeZoneCalculator: (): typeof timeZoneCalculator => (
timeZoneCalculator
),
};

const form = new AppointmentForm(formSchedulerProxy);

const noop = (): void => {};

const popupSchedulerProxy = {
getElement: (): ReturnType<typeof $> => $(container),
createComponent,
focus,
getResourceManager: (): ResourceManager => resourceManager,
getEditingConfig: (): typeof editing => editing,
getTimeZoneCalculator: (): typeof timeZoneCalculator => (
timeZoneCalculator
),
getDataAccessors: (): AppointmentDataAccessor => dataAccessors,
getAppointmentFormOpening: (): (
(...args: unknown[]) => void
) => options.onAppointmentFormOpening ?? noop,
processActionResult: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
arg: any,
callback: (canceled: boolean) => void,
): void => {
callback(arg.cancel);
},
addAppointment,
updateAppointment,
updateScrollPosition,
};

const popup = new AppointmentPopup(popupSchedulerProxy, form);

const appointmentData = options.appointmentData
?? { ...DEFAULT_APPOINTMENT };
const action = options.action ?? ACTION_TO_APPOINTMENT.CREATE;

popup.show(appointmentData, { action, allowSaving: true });
await new Promise(process.nextTick);

const selector = `.dx-overlay-wrapper.${APPOINTMENT_POPUP_CLASS}`;
const overlayWrapper = document.querySelector(
selector,
) as HTMLDivElement;

if (!overlayWrapper) {
throw new Error(
'AppointmentPopup overlay wrapper not found in DOM',
);
}

const POM = new PopupModel(overlayWrapper);

const dispose = (): void => {
popup.dispose();
container.remove();
};

disposables.push(dispose);

return {
container,
popup,
form,
POM,
callbacks: {
addAppointment,
updateAppointment,
focus,
updateScrollPosition,
},
dispose,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
afterEach, beforeEach, describe, expect, it,
} from '@jest/globals';

import fx from '../../../common/core/animation/fx';
import { ACTION_TO_APPOINTMENT } from '../appointment_popup/m_popup';
import {
createAppointmentPopup,
disposeAppointmentPopups,
} from './__mock__/create_appointment_popup';

describe('Isolated AppointmentPopup environment', () => {
beforeEach(() => {
fx.off = true;
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider calling setupSchedulerTestEnvironment() (or at least stubbing DOMComponent.prototype._isVisible) in beforeEach. Most other Scheduler jsdom tests do this to avoid :visible returning false in JSDOM, which can make overlay/popup rendering and visibility-dependent logic flaky.

Suggested change
fx.off = true;
fx.off = true;
setupSchedulerTestEnvironment();

Copilot uses AI. Check for mistakes.
});

afterEach(() => {
disposeAppointmentPopups();
fx.off = false;
});

it('should render popup with form fields', async () => {
const { POM } = await createAppointmentPopup();

expect(POM.element).toBeTruthy();
expect(POM.dxForm).toBeTruthy();
});

it('should display appointment data in form', async () => {
const { POM } = await createAppointmentPopup({
appointmentData: {
text: 'My Meeting',
startDate: new Date(2021, 3, 26, 9, 30),
endDate: new Date(2021, 3, 26, 11, 0),
},
});

expect(POM.getInputValue('subjectEditor')).toBe('My Meeting');
});

it('should have Save and Cancel buttons', async () => {
const { POM } = await createAppointmentPopup();

expect(POM.saveButton).toBeTruthy();
expect(POM.cancelButton).toBeTruthy();
});

it('should call addAppointment on Save click for CREATE action', async () => {
const { POM, callbacks } = await createAppointmentPopup({
appointmentData: {
text: 'New Appointment',
startDate: new Date(2021, 3, 26, 9, 30),
endDate: new Date(2021, 3, 26, 11, 0),
},
action: ACTION_TO_APPOINTMENT.CREATE,
});

POM.saveButton.click();

expect(callbacks.addAppointment).toHaveBeenCalledTimes(1);
expect(callbacks.addAppointment).toHaveBeenCalledWith(
expect.objectContaining({
text: 'New Appointment',
startDate: new Date(2021, 3, 26, 9, 30),
endDate: new Date(2021, 3, 26, 11, 0),
}),
);
});

it('should call updateAppointment on Save click for UPDATE action', async () => {
const { POM, callbacks } = await createAppointmentPopup({
appointmentData: {
text: 'Existing Appointment',
startDate: new Date(2021, 3, 26, 9, 30),
endDate: new Date(2021, 3, 26, 11, 0),
},
action: ACTION_TO_APPOINTMENT.UPDATE,
});

POM.saveButton.click();

expect(callbacks.updateAppointment).toHaveBeenCalledTimes(1);
});

it('should hide popup on Cancel click', async () => {
const { popup, POM } = await createAppointmentPopup();

expect(popup.visible).toBe(true);
POM.cancelButton.click();
expect(popup.visible).toBe(false);
});
});
Loading