From f5423e1be0eb5f21f56d69b05cba080b5f98f8d4 Mon Sep 17 00:00:00 2001 From: Vlada Skorokhodova Date: Fri, 8 May 2026 14:50:45 +0400 Subject: [PATCH 1/5] Scheduler: update Appointment Edit Form how-to --- .../Guides/SchedulerPreserveChanges/index.css | 10 + .../SchedulerPreserveChanges/index.html | 1 + .../Guides/SchedulerPreserveChanges/index.js | 220 ++++++ .../20 Preserve Unsaved Changes.md | 634 ++++++++++++++++++ 4 files changed, 865 insertions(+) create mode 100644 applications/UIWidgets/Guides/SchedulerPreserveChanges/index.css create mode 100644 applications/UIWidgets/Guides/SchedulerPreserveChanges/index.html create mode 100644 applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js create mode 100644 concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md diff --git a/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.css b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.css new file mode 100644 index 0000000000..8a8cc0a5dc --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.css @@ -0,0 +1,10 @@ +#scheduler { + height: 600px; +} + +/* "Discard Changes" button inside the appointment edit form */ +.discard-btn-item { + padding-top: 8px !important; + border-top: 1px solid #eee; + margin-top: 4px; +} diff --git a/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.html b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.html new file mode 100644 index 0000000000..5bda7d8c0e --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.html @@ -0,0 +1 @@ +
diff --git a/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js new file mode 100644 index 0000000000..3599de7493 --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js @@ -0,0 +1,220 @@ +$(function () { + const appointments = [ + { + id: 1, + text: 'Team Stand-up', + startDate: new Date(2026, 4, 8, 9, 0), + endDate: new Date(2026, 4, 8, 9, 30), + description: 'Daily morning sync with the full team.' + }, + { + id: 2, + text: 'Design Review', + startDate: new Date(2026, 4, 8, 11, 0), + endDate: new Date(2026, 4, 8, 12, 0), + description: 'Review latest UI mockups and prototypes.' + }, + { + id: 3, + text: 'Sprint Planning', + startDate: new Date(2026, 4, 11, 14, 0), + endDate: new Date(2026, 4, 11, 16, 0), + description: 'Plan tasks for the upcoming sprint.' + }, + { + id: 4, + text: 'Client Demo', + startDate: new Date(2026, 4, 12, 10, 0), + endDate: new Date(2026, 4, 12, 11, 30), + description: 'Showcase new features to the client.' + }, + { + id: 5, + text: 'Retrospective', + startDate: new Date(2026, 4, 13, 15, 0), + endDate: new Date(2026, 4, 13, 16, 0), + description: 'Sprint retrospective — what went well / what to improve.' + } + ]; + const DRAFT_PREFIX = 'dx-scheduler-draft-'; + + function getDraftKey(appointmentId) { + return DRAFT_PREFIX + (appointmentId != null ? appointmentId : 'new'); + } + + function saveDraft(appointmentId, formData) { + const key = getDraftKey(appointmentId); + const serializable = { + text: formData.text, + description: formData.description, + startDate: formData.startDate instanceof Date + ? formData.startDate.toISOString() + : formData.startDate, + endDate: formData.endDate instanceof Date + ? formData.endDate.toISOString() + : formData.endDate, + allDay: formData.allDay || false + }; + localStorage.setItem(key, JSON.stringify(serializable)); + } + + function loadDraft(appointmentId) { + const key = getDraftKey(appointmentId); + const raw = localStorage.getItem(key); + if (!raw) return null; + const data = JSON.parse(raw); + if (data.startDate) data.startDate = new Date(data.startDate); + if (data.endDate) data.endDate = new Date(data.endDate); + return data; + } + + function clearDraft(appointmentId) { + localStorage.removeItem(getDraftKey(appointmentId)); + } + let isSaved = false; + let currentAppointmentId = null; + let popupHidingHandler = null; + let currentPopup = null; + + + function onCanceled(form, originalData) { + if (isSaved) return; + + const formData = form.option('formData'); + const appointmentId = originalData && originalData.id != null + ? originalData.id + : null; + + saveDraft(appointmentId, formData); + + DevExpress.ui.notify({ + message: 'Draft saved. Your unsaved changes will be restored next time you open this appointment.', + type: 'warning', + displayTime: 4500, + position: { my: 'bottom center', at: 'bottom center', of: window } + }); + } + $('#scheduler').dxScheduler({ + dataSource: appointments, + currentDate: new Date(2026, 4, 8), + currentView: 'week', + views: ['day', 'week', 'month'], + startDayHour: 8, + endDayHour: 19, + height: 600, + editing: { + form: { + labelMode: 'floating', + items: [ + { + itemType: 'group', + name: 'mainGroup', + items: [ + 'subjectGroup', + 'dateGroup', + 'descriptionGroup', + 'repeatGroup', + 'resourcesGroup' + ] + }, + { + itemType: 'group', + name: 'recurrenceGroup' + } + ] + } + }, + onAppointmentFormOpening: function (e) { + const form = e.form; + const popup = e.popup; + const appointmentData = e.appointmentData || {}; + + isSaved = false; + currentAppointmentId = appointmentData.id != null ? appointmentData.id : null; + const draft = loadDraft(currentAppointmentId); + if (draft) { + const mergedData = $.extend({}, form.option('formData'), { + text: draft.text, + description: draft.description, + startDate: draft.startDate, + endDate: draft.endDate, + allDay: draft.allDay + }); + form.option('formData', mergedData); + const items = form.option('items'); + const hasDiscardBtn = items.some(function (item) { + return item.name === 'discardChangesButton'; + }); + + if (!hasDiscardBtn) { + const originalData = $.extend({}, appointmentData); + + items.push({ + itemType: 'button', + name: 'discardChangesButton', + horizontalAlignment: 'left', + cssClass: 'discard-btn-item', + buttonOptions: { + text: 'Discard Changes', + type: 'danger', + stylingMode: 'outlined', + icon: 'undo', + onClick: function () { + clearDraft(currentAppointmentId); + + form.option('formData', $.extend({}, form.option('formData'), { + text: originalData.text, + description: originalData.description, + startDate: originalData.startDate, + endDate: originalData.endDate, + allDay: originalData.allDay || false + })); + + form.option('items', form.option('items').filter(function (item) { + return item.name !== 'discardChangesButton'; + })); + + DevExpress.ui.notify({ + message: 'Changes discarded. Form reset to the last saved state.', + type: 'success', + displayTime: 3000 + }); + } + } + }); + + form.option('items', items); + } + + DevExpress.ui.notify({ + message: 'Unsaved draft restored. Use "Discard Changes" to revert to the saved appointment.', + type: 'info', + displayTime: 4000, + position: { my: 'bottom center', at: 'bottom center', of: window } + }); + } + if (currentPopup && popupHidingHandler) { + currentPopup.off('hiding', popupHidingHandler); + } + + currentPopup = popup; + popupHidingHandler = function () { + if (!isSaved) { + onCanceled(form, appointmentData); + } + }; + + popup.on('hiding', popupHidingHandler); + }, + + onAppointmentAdding: function () { + isSaved = true; + clearDraft(null); + }, + + onAppointmentUpdating: function () { + isSaved = true; + clearDraft(currentAppointmentId); + } + }); +}); diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md new file mode 100644 index 0000000000..8cc52f457b --- /dev/null +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md @@ -0,0 +1,634 @@ +When a user cancels the appointment edit form, unsaved changes are lost. To prevent data loss, you can save the current form state to `localStorage` when the form closes without saving, and restore the draft the next time the user opens the same appointment. + +
+ +This topic demonstrates how to: + +- Detect cancellation by listening to the Popup's `hiding` event inside [onAppointmentFormOpening](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentFormOpening.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentFormOpening'). +- Restore a saved draft and add a **Discard Changes** button when the form opens. +- Clear the draft after a successful save using [onAppointmentAdding](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentAdding.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentAdding') and [onAppointmentUpdating](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentUpdating.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentUpdating'). + +## Save a Draft on Cancel + +The Scheduler does not expose a dedicated cancel event. To detect cancellation, listen to the Popup's `hiding` event inside `onAppointmentFormOpening`. Guard with an `isSaved` flag so that the handler fires only when the user closes the form without saving: + +--- +##### jQuery + + + let isSaved = false; + + function onCanceled(form, originalData) { + const formData = form.option('formData'); + const appointmentId = originalData && originalData.id != null + ? originalData.id + : null; + + // Serialize dates as ISO strings for JSON compatibility + const draft = { + text: formData.text, + description: formData.description, + startDate: formData.startDate instanceof Date + ? formData.startDate.toISOString() + : formData.startDate, + endDate: formData.endDate instanceof Date + ? formData.endDate.toISOString() + : formData.endDate, + allDay: formData.allDay || false, + }; + localStorage.setItem('dx-scheduler-draft-' + (appointmentId ?? 'new'), JSON.stringify(draft)); + } + + $('#scheduler').dxScheduler({ + // ... + onAppointmentFormOpening: function (e) { + const form = e.form; + const popup = e.popup; + const appointmentData = e.appointmentData || {}; + + isSaved = false; + + popup.on('hiding', function () { + if (!isSaved) { + onCanceled(form, appointmentData); + } + }); + }, + }); + +##### Angular + + + + + + import { Component } from '@angular/core'; + import { DxSchedulerModule, type DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + standalone: true, + imports: [DxSchedulerModule], + }) + export class AppComponent { + private isSaved = false; + + private onCanceled( + form: DxSchedulerTypes.AppointmentFormOpeningEvent['form'], + originalData: Record, + ): void { + const formData = form.option('formData') as Record; + const appointmentId = originalData?.['id'] != null ? originalData['id'] : null; + + const draft = { + text: formData['text'], + description: formData['description'], + startDate: formData['startDate'] instanceof Date + ? (formData['startDate'] as Date).toISOString() + : formData['startDate'], + endDate: formData['endDate'] instanceof Date + ? (formData['endDate'] as Date).toISOString() + : formData['endDate'], + allDay: formData['allDay'] ?? false, + }; + localStorage.setItem( + `dx-scheduler-draft-${appointmentId ?? 'new'}`, + JSON.stringify(draft), + ); + } + + handleAppointmentFormOpening(e: DxSchedulerTypes.AppointmentFormOpeningEvent): void { + const { form, popup } = e; + const appointmentData = (e.appointmentData ?? {}) as Record; + + this.isSaved = false; + + popup.on('hiding', () => { + if (!this.isSaved) { + this.onCanceled(form, appointmentData); + } + }); + } + } + +##### Vue + + + + + + +##### React + + + import { useRef, useCallback } from 'react'; + import { Scheduler, type SchedulerTypes } from 'devextreme-react/scheduler'; + + function onCanceled( + form: SchedulerTypes.AppointmentFormOpeningEvent['form'], + originalData: Record, + isSaved: React.MutableRefObject, + ): void { + if (isSaved.current) return; + + const formData = form.option('formData') as Record; + const appointmentId = originalData?.['id'] != null ? originalData['id'] : null; + + const draft = { + text: formData['text'], + description: formData['description'], + startDate: formData['startDate'] instanceof Date + ? (formData['startDate'] as Date).toISOString() + : formData['startDate'], + endDate: formData['endDate'] instanceof Date + ? (formData['endDate'] as Date).toISOString() + : formData['endDate'], + allDay: formData['allDay'] ?? false, + }; + localStorage.setItem( + `dx-scheduler-draft-${appointmentId ?? 'new'}`, + JSON.stringify(draft), + ); + } + + function App() { + const isSaved = useRef(false); + + const handleAppointmentFormOpening = useCallback( + (e: SchedulerTypes.AppointmentFormOpeningEvent) => { + const { form, popup } = e; + const appointmentData = (e.appointmentData ?? {}) as Record; + + isSaved.current = false; + + popup.on('hiding', () => { + onCanceled(form, appointmentData, isSaved); + }); + }, + [], + ); + + return ( + + ); + } + + export default App; + +--- + +[note] Remove any previously registered `hiding` handler before registering a new one to prevent duplicate draft saves when the same appointment is opened multiple times. + +## Restore a Draft and Add a Discard Changes Button + +When the edit form opens, check `localStorage` for a saved draft. If a draft exists, restore its values into the form with `form.option('formData', …)` and add a **Discard Changes** button so the user can revert to the appointment's last saved state: + +--- +##### jQuery + + + onAppointmentFormOpening: function (e) { + const form = e.form; + const appointmentData = e.appointmentData || {}; + const appointmentId = appointmentData.id != null ? appointmentData.id : null; + // Restore draft + const raw = localStorage.getItem('dx-scheduler-draft-' + (appointmentId ?? 'new')); + if (raw) { + const draft = JSON.parse(raw); + if (draft.startDate) draft.startDate = new Date(draft.startDate); + if (draft.endDate) draft.endDate = new Date(draft.endDate); + + form.option('formData', Object.assign({}, form.option('formData'), draft)); + + // Add Discard Changes button + const originalData = Object.assign({}, appointmentData); + const items = form.option('items'); + const alreadyAdded = items.some(function (item) { + return item.name === 'discardChangesButton'; + }); + + if (!alreadyAdded) { + items.push({ + itemType: 'button', + name: 'discardChangesButton', + horizontalAlignment: 'left', + buttonOptions: { + text: 'Discard Changes', + type: 'danger', + stylingMode: 'outlined', + icon: 'undo', + onClick: function () { + localStorage.removeItem('dx-scheduler-draft-' + (appointmentId ?? 'new')); + + form.option('formData', Object.assign({}, form.option('formData'), { + text: originalData.text, + description: originalData.description, + startDate: originalData.startDate, + endDate: originalData.endDate, + allDay: originalData.allDay || false, + })); + + form.option('items', form.option('items').filter(function (item) { + return item.name !== 'discardChangesButton'; + })); + }, + }, + }); + form.option('items', items); + } + } + + // Wire up cancel detection + // ... + }, + +##### Angular + + + + + + import { type DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; + // ... + export class AppComponent { + // ... + handleAppointmentFormOpening(e: DxSchedulerTypes.AppointmentFormOpeningEvent): void { + const { form } = e; + const appointmentData = (e.appointmentData ?? {}) as Record; + const appointmentId = appointmentData['id'] != null ? appointmentData['id'] : null; + + // Restore draft + const raw = localStorage.getItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`); + if (raw) { + const draft = JSON.parse(raw) as Record; + if (draft['startDate']) draft['startDate'] = new Date(draft['startDate'] as string); + if (draft['endDate']) draft['endDate'] = new Date(draft['endDate'] as string); + + form.option('formData', { ...form.option('formData') as object, ...draft }); + + // Add Discard Changes button + const originalData = { ...appointmentData }; + const items = form.option('items') as object[]; + const alreadyAdded = items.some((item) => (item as { name?: string })['name'] === 'discardChangesButton'); + + if (!alreadyAdded) { + items.push({ + itemType: 'button', + name: 'discardChangesButton', + horizontalAlignment: 'left', + buttonOptions: { + text: 'Discard Changes', + type: 'danger', + stylingMode: 'outlined', + icon: 'undo', + onClick: () => { + localStorage.removeItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`); + + form.option('formData', { + ...form.option('formData') as object, + text: originalData['text'], + description: originalData['description'], + startDate: originalData['startDate'], + endDate: originalData['endDate'], + allDay: originalData['allDay'] ?? false, + }); + + form.option( + 'items', + (form.option('items') as object[]).filter( + (item) => (item as { name?: string })['name'] !== 'discardChangesButton', + ), + ); + }, + }, + }); + form.option('items', items); + } + } + + // Wire up cancel detection + // ... + } + } + +##### Vue + + + + + + +##### React + + + import { useRef, useCallback } from 'react'; + import { Scheduler, type SchedulerTypes } from 'devextreme-react/scheduler'; + + function App() { + const isSaved = useRef(false); + + const handleAppointmentFormOpening = useCallback( + (e: SchedulerTypes.AppointmentFormOpeningEvent) => { + const { form } = e; + const appointmentData = (e.appointmentData ?? {}) as Record; + const appointmentId = appointmentData['id'] != null ? appointmentData['id'] : null; + + // Restore draft + const raw = localStorage.getItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`); + if (raw) { + const draft = JSON.parse(raw) as Record; + if (draft['startDate']) draft['startDate'] = new Date(draft['startDate'] as string); + if (draft['endDate']) draft['endDate'] = new Date(draft['endDate'] as string); + + form.option('formData', { ...form.option('formData') as object, ...draft }); + + // Add Discard Changes button + const originalData = { ...appointmentData }; + const items = form.option('items') as object[]; + const alreadyAdded = items.some( + (item) => (item as { name?: string })['name'] === 'discardChangesButton', + ); + + if (!alreadyAdded) { + items.push({ + itemType: 'button', + name: 'discardChangesButton', + horizontalAlignment: 'left', + buttonOptions: { + text: 'Discard Changes', + type: 'danger', + stylingMode: 'outlined', + icon: 'undo', + onClick: () => { + localStorage.removeItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`); + + form.option('formData', { + ...form.option('formData') as object, + text: originalData['text'], + description: originalData['description'], + startDate: originalData['startDate'], + endDate: originalData['endDate'], + allDay: originalData['allDay'] ?? false, + }); + + form.option( + 'items', + (form.option('items') as object[]).filter( + (item) => (item as { name?: string })['name'] !== 'discardChangesButton', + ), + ); + }, + }, + }); + form.option('items', items); + } + } + + // Wire up cancel detection + // ... + }, + [], + ); + + return ( + + ); + } + export default App; + +--- + + +## Clear the Draft After a Successful Save + +Clear the draft from `localStorage` after the user saves the appointment to avoid restoring stale data on subsequent opens: + +--- + +##### jQuery + + + $('#scheduler').dxScheduler({ + // ... + onAppointmentAdding: function () { + isSaved = true; + localStorage.removeItem('dx-scheduler-draft-new'); + }, + onAppointmentUpdating: function (e) { + isSaved = true; + const appointmentId = e.oldData && e.oldData.id != null ? e.oldData.id : null; + localStorage.removeItem('dx-scheduler-draft-' + appointmentId); + }, + }); + +##### Angular + + + + + + import { type DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; + // ... + export class AppComponent { + // ... + handleAppointmentAdding(): void { + this.isSaved = true; + localStorage.removeItem('dx-scheduler-draft-new'); + } + + handleAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void { + this.isSaved = true; + const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null; + localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`); + } + } + +##### Vue + + + + + + +##### React + + + import { useRef, useCallback } from 'react'; + import { Scheduler, type SchedulerTypes } from 'devextreme-react/scheduler'; + + function App() { + const isSaved = useRef(false); + + const handleAppointmentAdding = useCallback(() => { + isSaved.current = true; + localStorage.removeItem('dx-scheduler-draft-new'); + }, []); + + const handleAppointmentUpdating = useCallback( + (e: SchedulerTypes.AppointmentUpdatingEvent) => { + isSaved.current = true; + const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null; + localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`); + }, + [], + ); + + return ( + + ); + } + + export default App; + +--- From 287fa978f8ea1729890700b8df4eeb4c9f41f364 Mon Sep 17 00:00:00 2001 From: Vlada Skorokhodova Date: Wed, 13 May 2026 12:42:35 +0400 Subject: [PATCH 2/5] Add Recreate the Legacy Form Layout --- .../Guides/SchedulerLegacyForm/index.css | 3 + .../Guides/SchedulerLegacyForm/index.html | 1 + .../Guides/SchedulerLegacyForm/index.js | 257 ++++++++ .../20 Preserve Unsaved Changes.md | 12 +- .../25 Recreate the Legacy Form Layout.md | 624 ++++++++++++++++++ 5 files changed, 888 insertions(+), 9 deletions(-) create mode 100644 applications/UIWidgets/Guides/SchedulerLegacyForm/index.css create mode 100644 applications/UIWidgets/Guides/SchedulerLegacyForm/index.html create mode 100644 applications/UIWidgets/Guides/SchedulerLegacyForm/index.js create mode 100644 concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md diff --git a/applications/UIWidgets/Guides/SchedulerLegacyForm/index.css b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.css new file mode 100644 index 0000000000..9523cbf80c --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.css @@ -0,0 +1,3 @@ +#scheduler { + height: 600px; +} diff --git a/applications/UIWidgets/Guides/SchedulerLegacyForm/index.html b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.html new file mode 100644 index 0000000000..5bda7d8c0e --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.html @@ -0,0 +1 @@ +
diff --git a/applications/UIWidgets/Guides/SchedulerLegacyForm/index.js b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.js new file mode 100644 index 0000000000..164cd99a0c --- /dev/null +++ b/applications/UIWidgets/Guides/SchedulerLegacyForm/index.js @@ -0,0 +1,257 @@ +$(function () { + const assignees = [ + { text: 'Samantha Bright', id: 1, color: '#727bd2' }, + { text: 'John Heart', id: 2, color: '#32c9ed' }, + { text: 'Todd Hoffman', id: 3, color: '#2a7ee4' }, + { text: 'Sandra Johnson', id: 4, color: '#7b49d3' }, + ]; + + const rooms = [ + { text: 'Room 1', id: 1, color: '#00af2c' }, + { text: 'Room 2', id: 2, color: '#56ca85' }, + { text: 'Room 3', id: 3, color: '#8ecd3c' }, + ]; + + const priorities = [ + { text: 'High', id: 1, color: '#cc5c53' }, + { text: 'Low', id: 2, color: '#ff9747' }, + ]; + + const resources = [ + { fieldExpr: 'roomId', dataSource: rooms, label: 'Room' }, + { fieldExpr: 'priorityId', dataSource: priorities, label: 'Priority' }, + { fieldExpr: 'assigneeId', allowMultiple: true, dataSource: assignees, label: 'Assignee' }, + ]; + + const schedulerData = [ + { + text: 'Watercolor Landscape', + assigneeId: [4], roomId: 1, priorityId: 2, + startDate: new Date('2026-04-27T17:30:00.000Z'), + endDate: new Date('2026-04-27T19:00:00.000Z'), + recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO,TH;COUNT=10', + }, + { + text: 'Website Re-Design Plan', + assigneeId: [4], roomId: 1, priorityId: 2, + startDate: new Date('2026-04-27T16:30:00.000Z'), + endDate: new Date('2026-04-27T18:30:00.000Z'), + }, + { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [2], roomId: 2, priorityId: 1, + startDate: new Date('2026-04-27T19:00:00.000Z'), + endDate: new Date('2026-04-27T20:00:00.000Z'), + allDay: true, + }, + { + text: 'Install New Router in Dev Room', + assigneeId: [1], roomId: 1, priorityId: 2, + startDate: new Date('2026-04-27T21:30:00.000Z'), + endDate: new Date('2026-04-27T22:30:00.000Z'), + }, + { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], roomId: 2, priorityId: 2, + startDate: new Date('2026-04-28T17:00:00.000Z'), + endDate: new Date('2026-04-28T18:00:00.000Z'), + }, + { + text: 'Final Budget Review', + assigneeId: [1], roomId: 1, priorityId: 1, + startDate: new Date('2026-04-28T19:00:00.000Z'), + endDate: new Date('2026-04-28T20:35:00.000Z'), + }, + { + text: 'New Brochures', + assigneeId: [4], roomId: 3, priorityId: 2, + startDate: new Date('2026-04-28T21:30:00.000Z'), + endDate: new Date('2026-04-28T22:45:00.000Z'), + }, + { + text: 'Install New Database', + assigneeId: [2], roomId: 3, priorityId: 1, + startDate: new Date('2026-04-29T16:45:00.000Z'), + endDate: new Date('2026-04-29T18:15:00.000Z'), + }, + { + text: 'Approve New Online Marketing Strategy', + assigneeId: [4], roomId: 2, priorityId: 1, + startDate: new Date('2026-04-29T19:00:00.000Z'), + endDate: new Date('2026-04-29T21:00:00.000Z'), + }, + { + text: 'Upgrade Personal Computers', + assigneeId: [2], roomId: 2, priorityId: 2, + startDate: new Date('2026-04-29T22:15:00.000Z'), + endDate: new Date('2026-04-29T23:30:00.000Z'), + }, + { + text: 'Customer Workshop', + assigneeId: [3], roomId: 3, priorityId: 1, + startDate: new Date('2026-04-30T18:00:00.000Z'), + endDate: new Date('2026-04-30T19:00:00.000Z'), + allDay: true, + }, + { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [1], roomId: 1, priorityId: 2, + startDate: new Date('2026-04-30T18:00:00.000Z'), + endDate: new Date('2026-04-30T20:30:00.000Z'), + }, + { + text: 'Brochure Design Review', + assigneeId: [4], roomId: 1, priorityId: 1, + startDate: new Date('2026-04-30T21:00:00.000Z'), + endDate: new Date('2026-04-30T22:30:00.000Z'), + }, + { + text: 'Create Icons for Website', + assigneeId: [3], roomId: 3, priorityId: 1, + startDate: new Date('2026-05-01T17:00:00.000Z'), + endDate: new Date('2026-05-01T18:30:00.000Z'), + }, + { + text: 'Upgrade Server Hardware', + assigneeId: [4], roomId: 2, priorityId: 2, + startDate: new Date('2026-05-01T21:30:00.000Z'), + endDate: new Date('2026-05-01T23:00:00.000Z'), + }, + { + text: 'Submit New Website Design', + assigneeId: [1], roomId: 1, priorityId: 2, + startDate: new Date('2026-05-01T23:30:00.000Z'), + endDate: new Date('2026-05-02T01:00:00.000Z'), + }, + { + text: 'Launch New Website', + assigneeId: [2], roomId: 3, priorityId: 1, + startDate: new Date('2026-05-01T19:20:00.000Z'), + endDate: new Date('2026-05-01T21:00:00.000Z'), + }, + ]; + + const legacyFormItems = [ + { + name: 'mainGroup', + cssClass: '', + items: [ + 'subjectGroup', + 'dateGroup', + { + name: 'repeatGroup', + items: [ + 'repeatIcon', + { + name: 'customRepeatEditor', + editorType: 'dxSwitch', + dataField: 'repeat', + label: { text: 'Repeat' }, + editorOptions: {}, + }, + ], + }, + 'resourcesGroup', + 'descriptionGroup', + ], + }, + { + name: 'recurrenceGroup', + itemType: 'group', + cssClass: '', + visible: false, + items: [ + 'recurrenceRuleGroup', + 'recurrenceEndGroup', + ], + }, + ]; + + function applyRepeatState(form, isRepeat) { + if (isRepeat) { + const recurrenceRule = form.option('formData') && form.option('formData').recurrenceRule; + form.option('colCount', 2); + form.option('formData.recurrenceRule', recurrenceRule || 'FREQ=DAILY'); + form.itemOption('recurrenceGroup', 'visible', true); + } else { + form.option('colCount', 1); + form.option('formData.recurrenceRule', ''); + form.itemOption('recurrenceGroup', 'visible', false); + } + } + + $('#scheduler').dxScheduler({ + dataSource: schedulerData, + resources: resources, + height: 600, + views: ['day', 'week', 'workWeek', 'month'], + currentView: 'workWeek', + currentDate: new Date(2026, 3, 29), + + onAppointmentAdding: function (e) { + delete e.appointmentData.repeat; + }, + onAppointmentUpdating: function (e) { + delete e.newData.repeat; + }, + + onAppointmentFormOpening: function (e) { + const form = e.form; + + form.on('fieldDataChanged', function (fe) { + if (fe.dataField === 'recurrenceRule') { + form.option('formData.repeat', !!fe.value); + } + }); + + const hasRecurrence = !!(e.appointmentData && e.appointmentData.recurrenceRule); + form.option('formData.repeat', hasRecurrence); + applyRepeatState(form, hasRecurrence); + + form.itemOption('mainGroup.repeatGroup.customRepeatEditor', 'editorOptions', { + onValueChanged: function (ve) { + applyRepeatState(form, ve.value); + }, + }); + }, + + editing: { + allowAdding: true, + allowUpdating: true, + allowDeleting: true, + allowDragging: true, + allowResizing: true, + allowTimeZoneEditing: false, + popup: { + maxWidth: 800, + toolbarItems: [ + { + toolbar: 'top', + location: 'before', + text: 'Edit Appointment', + cssClass: 'dx-toolbar-label', + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'done', + options: { + stylingMode: 'contained', + type: 'default', + text: 'Save', + }, + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'cancel', + }, + ], + }, + form: { + iconsShowMode: 'none', + items: legacyFormItems, + }, + }, + }); +}); diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md index 8cc52f457b..5d57eb31ef 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md @@ -2,13 +2,7 @@ When a user cancels the appointment edit form, unsaved changes are lost. To prev
-This topic demonstrates how to: - -- Detect cancellation by listening to the Popup's `hiding` event inside [onAppointmentFormOpening](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentFormOpening.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentFormOpening'). -- Restore a saved draft and add a **Discard Changes** button when the form opens. -- Clear the draft after a successful save using [onAppointmentAdding](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentAdding.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentAdding') and [onAppointmentUpdating](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentUpdating.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentUpdating'). - -## Save a Draft on Cancel +### Save a Draft on Cancel The Scheduler does not expose a dedicated cancel event. To detect cancellation, listen to the Popup's `hiding` event inside `onAppointmentFormOpening`. Guard with an `isSaved` flag so that the handler fires only when the user closes the form without saving: @@ -230,7 +224,7 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, [note] Remove any previously registered `hiding` handler before registering a new one to prevent duplicate draft saves when the same appointment is opened multiple times. -## Restore a Draft and Add a Discard Changes Button +### Restore a Draft and Add a Discard Changes Button When the edit form opens, check `localStorage` for a saved draft. If a draft exists, restore its values into the form with `form.option('formData', …)` and add a **Discard Changes** button so the user can revert to the appointment's last saved state: @@ -523,7 +517,7 @@ When the edit form opens, check `localStorage` for a saved draft. If a draft exi --- -## Clear the Draft After a Successful Save +### Clear the Draft After a Successful Save Clear the draft from `localStorage` after the user saves the appointment to avoid restoring stale data on subsequent opens: diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md new file mode 100644 index 0000000000..16c23076fa --- /dev/null +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md @@ -0,0 +1,624 @@ +In DevExtreme v25.2, the appointment edit form was updated with a new layout. If your application depends on the previous form layout, you can replicate it using [editing.form.items](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/editing/form/items.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/editing/form/#items') and [onAppointmentFormOpening](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentFormOpening.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentFormOpening'). + +
+ +### Define the Legacy Form Layout + +Use **editing**.**form**.[items[]](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/editing/form/items.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/editing/form/#items') to rearrange predefined groups and inject a custom repeat toggle. Both `mainGroup` and `recurrenceGroup` must stay at the root level. + +Two details are critical for the recurrence panel to work correctly: + +- Set `cssClass: ''` on both `mainGroup` and `recurrenceGroup`. The Scheduler internally uses the `dx-scheduler-form-recurrence-group-hidden` CSS class to control `recurrenceGroup` visibility. Setting `cssClass: ''` prevents that class from being applied and lets you control visibility manually. +- Set `visible: false` on `recurrenceGroup` so the panel starts hidden. List its children explicitly so the Scheduler initializes the recurrence editors on form build: + +--- + +##### jQuery + + + const legacyFormItems = [ + { + name: 'mainGroup', + cssClass: '', + items: [ + 'subjectGroup', + 'dateGroup', + { + name: 'repeatGroup', + items: [ + 'repeatIcon', + { + name: 'customRepeatEditor', + editorType: 'dxSwitch', + dataField: 'repeat', + label: { text: 'Repeat' }, + editorOptions: {}, + }, + ], + }, + 'resourcesGroup', + 'descriptionGroup', + ], + }, + { + name: 'recurrenceGroup', + itemType: 'group', + cssClass: '', + visible: false, + items: [ + 'recurrenceRuleGroup', + 'recurrenceEndGroup', + ], + }, + ]; + + $('#scheduler').dxScheduler({ + editing: { + form: { + iconsShowMode: 'none', + items: legacyFormItems, + }, + }, + }); + +##### Angular + + + + + + + + + + + + + + + + + + + + + + + +##### Vue + + + + + + +##### React + + + import { Scheduler, Editing, Form, Item } from 'devextreme-react/scheduler'; + + const legacyFormItems = [ + { + name: 'mainGroup', + cssClass: '', + items: [ + 'subjectGroup', + 'dateGroup', + { + name: 'repeatGroup', + items: [ + 'repeatIcon', + { + name: 'customRepeatEditor', + editorType: 'dxSwitch', + dataField: 'repeat', + label: { text: 'Repeat' }, + editorOptions: {}, + }, + ], + }, + 'resourcesGroup', + 'descriptionGroup', + ], + }, + { + name: 'recurrenceGroup', + itemType: 'group' as const, + cssClass: '', + visible: false, + items: [ + 'recurrenceRuleGroup', + 'recurrenceEndGroup', + ], + }, + ]; + + function App() { + return ( + + +
+ + + ); + } + + export default App; + +--- + +Set [iconsShowMode](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/editing/form/iconsShowMode.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/editing/form/#iconsShowMode') to `'none'` to hide the group prefix icons that appear in the default v25.2 layout. + +Refer to [Predefined Items](/concepts/05%20UI%20Components/Scheduler/030%20Appointments/070%20Appointment%20Edit%20Form/15%20Predefined%20Items.md '/Documentation/Guide/UI_Components/Scheduler/Appointments/Appointment_Edit_Form/#Predefined_Items') for the full list of available group and item names. + +### Customize the Popup Toolbar + +Use [editing.popup](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/editing/popup.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#editing') to move the action buttons to the top toolbar and add a title label, matching the legacy popup appearance: + +--- + +##### jQuery + + + $('#scheduler').dxScheduler({ + editing: { + popup: { + maxWidth: 800, + toolbarItems: [ + { + toolbar: 'top', + location: 'before', + text: 'Edit Appointment', + cssClass: 'dx-toolbar-label', + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'done', + options: { + stylingMode: 'contained', + type: 'default', + text: 'Save', + }, + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'cancel', + }, + ], + }, + }, + }); + +##### Angular + + + + + + + + + + import { Component } from '@angular/core'; + import { DxSchedulerModule } from 'devextreme-angular/ui/scheduler'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + standalone: true, + imports: [DxSchedulerModule], + }) + export class AppComponent { + popupToolbarItems = [ + { + toolbar: 'top', + location: 'before', + text: 'Edit Appointment', + cssClass: 'dx-toolbar-label', + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'done', + options: { + stylingMode: 'contained', + type: 'default', + text: 'Save', + }, + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'cancel', + }, + ]; + } + +##### Vue + + + + + + +##### React + + + import { Scheduler, Editing, Popup } from 'devextreme-react/scheduler'; + + const popupToolbarItems = [ + { + toolbar: 'top', + location: 'before', + text: 'Edit Appointment', + cssClass: 'dx-toolbar-label', + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'done', + options: { + stylingMode: 'contained', + type: 'default', + text: 'Save', + }, + }, + { + toolbar: 'top', + location: 'after', + shortcut: 'cancel', + }, + ]; + + function App() { + return ( + + + + + + ); + } + + export default App; + +--- + +### Wire Up the Repeat Toggle + +The built-in recurrence panel switch fires when the user changes the **Repeat** drop-down in the default form. Because the legacy form replaces that drop-down with a custom `dxSwitch`, you must wire up the toggle logic manually in `onAppointmentFormOpening`. + +The `applyRepeatState` helper toggles the layout between one and two columns, displays or hides `recurrenceGroup`, and sets or clears `formData.recurrenceRule`. All three operations must happen together. + +`cssClass: ''` on `recurrenceGroup` (set in the previous section) is what makes this possible: it prevents the Scheduler from applying its internal `dx-scheduler-form-recurrence-group-hidden` class, so `form.itemOption('recurrenceGroup', 'visible', ...)` has full control over the panel. + +The `fieldDataChanged` listener keeps the toggle in sync when the user edits the recurrence rule from inside the panel (which can also clear the rule): + +--- + +##### jQuery + + + function applyRepeatState(form, isRepeat) { + if (isRepeat) { + const recurrenceRule = form.option('formData') && form.option('formData').recurrenceRule; + form.option('colCount', 2); + form.option('formData.recurrenceRule', recurrenceRule || 'FREQ=DAILY'); + form.itemOption('recurrenceGroup', 'visible', true); + } else { + form.option('colCount', 1); + form.option('formData.recurrenceRule', ''); + form.itemOption('recurrenceGroup', 'visible', false); + } + } + + $('#scheduler').dxScheduler({ + onAppointmentFormOpening: function (e) { + const form = e.form; + + form.on('fieldDataChanged', function (fe) { + if (fe.dataField === 'recurrenceRule') { + form.option('formData.repeat', !!fe.value); + } + }); + + const hasRecurrence = !!(e.appointmentData && e.appointmentData.recurrenceRule); + form.option('formData.repeat', hasRecurrence); + applyRepeatState(form, hasRecurrence); + + form.itemOption('mainGroup.repeatGroup.customRepeatEditor', 'editorOptions', { + onValueChanged: function (ve) { + applyRepeatState(form, ve.value); + }, + }); + }, + + onAppointmentAdding: function (e) { + delete e.appointmentData.repeat; + }, + onAppointmentUpdating: function (e) { + delete e.newData.repeat; + }, + }); + +##### Angular + + + + + + import { Component } from '@angular/core'; + import { DxSchedulerModule, type DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + standalone: true, + imports: [DxSchedulerModule], + }) + export class AppComponent { + private applyRepeatState( + form: DxSchedulerTypes.AppointmentFormOpeningEvent['form'], + isRepeat: boolean, + ): void { + if (isRepeat) { + const recurrenceRule = (form.option('formData') as Record)?.['recurrenceRule'] as string; + form.option('colCount', 2); + form.option('formData.recurrenceRule', recurrenceRule || 'FREQ=DAILY'); + form.itemOption('recurrenceGroup', 'visible', true); + } else { + form.option('colCount', 1); + form.option('formData.recurrenceRule', ''); + form.itemOption('recurrenceGroup', 'visible', false); + } + } + + handleAppointmentFormOpening(e: DxSchedulerTypes.AppointmentFormOpeningEvent): void { + const { form } = e; + + form.on('fieldDataChanged', (fe: { dataField: string; value: unknown }) => { + if (fe.dataField === 'recurrenceRule') { + form.option('formData.repeat', !!fe.value); + } + }); + + const hasRecurrence = !!(e.appointmentData && e.appointmentData['recurrenceRule']); + form.option('formData.repeat', hasRecurrence); + this.applyRepeatState(form, hasRecurrence); + + form.itemOption('mainGroup.repeatGroup.customRepeatEditor', 'editorOptions', { + onValueChanged: (ve: { value: boolean }) => { + this.applyRepeatState(form, ve.value); + }, + }); + } + + handleAppointmentAdding(e: DxSchedulerTypes.AppointmentAddingEvent): void { + delete (e.appointmentData as Record)['repeat']; + } + + handleAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void { + delete (e.newData as Record)['repeat']; + } + } + +##### Vue + + + + + + +##### React + + + import { useCallback } from 'react'; + import { Scheduler, type SchedulerTypes } from 'devextreme-react/scheduler'; + + function applyRepeatState( + form: SchedulerTypes.AppointmentFormOpeningEvent['form'], + isRepeat: boolean, + ): void { + if (isRepeat) { + const recurrenceRule = (form.option('formData') as Record)?.['recurrenceRule'] as string; + form.option('colCount', 2); + form.option('formData.recurrenceRule', recurrenceRule || 'FREQ=DAILY'); + form.itemOption('recurrenceGroup', 'visible', true); + } else { + form.option('colCount', 1); + form.option('formData.recurrenceRule', ''); + form.itemOption('recurrenceGroup', 'visible', false); + } + } + + function App() { + const handleAppointmentFormOpening = useCallback( + (e: SchedulerTypes.AppointmentFormOpeningEvent) => { + const { form } = e; + + form.on('fieldDataChanged', (fe: { dataField: string; value: unknown }) => { + if (fe.dataField === 'recurrenceRule') { + form.option('formData.repeat', !!fe.value); + } + }); + + const hasRecurrence = !!(e.appointmentData && e.appointmentData['recurrenceRule']); + form.option('formData.repeat', hasRecurrence); + applyRepeatState(form, hasRecurrence); + + form.itemOption('mainGroup.repeatGroup.customRepeatEditor', 'editorOptions', { + onValueChanged: (ve: { value: boolean }) => { + applyRepeatState(form, ve.value); + }, + }); + }, + [], + ); + + const handleAppointmentAdding = useCallback( + (e: SchedulerTypes.AppointmentAddingEvent) => { + delete (e.appointmentData as Record)['repeat']; + }, + [], + ); + + const handleAppointmentUpdating = useCallback( + (e: SchedulerTypes.AppointmentUpdatingEvent) => { + delete (e.newData as Record)['repeat']; + }, + [], + ); + + return ( + + ); + } + + export default App; + +--- + +The `repeat` field is a transient UI-only value. Remove it from the appointment data in [onAppointmentAdding](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentAdding.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentAdding') and [onAppointmentUpdating](/api-reference/10%20UI%20Components/dxScheduler/1%20Configuration/onAppointmentUpdating.md '/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentUpdating') so it is never written to the data source. From ec0ba69201fb28a08bbdeb6830842c461143f185 Mon Sep 17 00:00:00 2001 From: Vlada Skorokhodova <94827090+vladaskorohodova@users.noreply.github.com> Date: Wed, 13 May 2026 12:57:31 +0400 Subject: [PATCH 3/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../070 Appointment Edit Form/20 Preserve Unsaved Changes.md | 2 +- .../25 Recreate the Legacy Form Layout.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md index 5d57eb31ef..6b76e2c630 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md @@ -1,4 +1,4 @@ -When a user cancels the appointment edit form, unsaved changes are lost. To prevent data loss, you can save the current form state to `localStorage` when the form closes without saving, and restore the draft the next time the user opens the same appointment. +Unsaved changes are lost when a user cancels the appointment edit form. To prevent data loss, you can save the current form state to `localStorage` when the form closes without saving, and restore the draft the next time the user opens the same appointment.
diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md index 16c23076fa..0a5ccaffc0 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md @@ -369,7 +369,7 @@ Use [editing.popup](/api-reference/10%20UI%20Components/dxScheduler/1%20Configur ### Wire Up the Repeat Toggle -The built-in recurrence panel switch fires when the user changes the **Repeat** drop-down in the default form. Because the legacy form replaces that drop-down with a custom `dxSwitch`, you must wire up the toggle logic manually in `onAppointmentFormOpening`. +The built-in recurrence panel switch fires when the user changes the **Repeat** drop-down in the default form. You must wire up the toggle logic manually in `onAppointmentFormOpening` because the legacy form replaces that drop-down with a custom `dxSwitch`. The `applyRepeatState` helper toggles the layout between one and two columns, displays or hides `recurrenceGroup`, and sets or clears `formData.recurrenceRule`. All three operations must happen together. From e9e408231054605236a7864a5716c0b5c997b1bd Mon Sep 17 00:00:00 2001 From: Vlada Skorokhodova Date: Thu, 14 May 2026 18:13:08 +0400 Subject: [PATCH 4/5] Update after review --- .../Guides/SchedulerPreserveChanges/index.js | 6 ++- .../20 Preserve Unsaved Changes.md | 39 +++++++++++++------ .../25 Recreate the Legacy Form Layout.md | 2 +- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js index 3599de7493..8c2092c846 100644 --- a/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js +++ b/applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js @@ -161,6 +161,7 @@ $(function () { icon: 'undo', onClick: function () { clearDraft(currentAppointmentId); + isSaved = true; form.option('formData', $.extend({}, form.option('formData'), { text: originalData.text, @@ -212,9 +213,10 @@ $(function () { clearDraft(null); }, - onAppointmentUpdating: function () { + onAppointmentUpdating: function (e) { isSaved = true; - clearDraft(currentAppointmentId); + const appointmentId = e && e.oldData && e.oldData.id != null ? e.oldData.id : null; + clearDraft(appointmentId); } }); }); diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md index 5d57eb31ef..077b8b4905 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md @@ -1,4 +1,4 @@ -When a user cancels the appointment edit form, unsaved changes are lost. To prevent data loss, you can save the current form state to `localStorage` when the form closes without saving, and restore the draft the next time the user opens the same appointment. +Canceling the appointment edit form discards unsaved changes. You can prevent data loss by saving the current form state to `localStorage` when the form closes without saving and restoring the draft the next time the user opens the same appointment.
@@ -11,6 +11,7 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, let isSaved = false; + let hidingHandler = null; function onCanceled(form, originalData) { const formData = form.option('formData'); @@ -42,11 +43,13 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, isSaved = false; - popup.on('hiding', function () { + popup.off('hiding', hidingHandler); + hidingHandler = function () { if (!isSaved) { onCanceled(form, appointmentData); } - }); + }; + popup.on('hiding', hidingHandler); }, }); @@ -69,6 +72,7 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, }) export class AppComponent { private isSaved = false; + private hidingHandler: (() => void) | null = null; private onCanceled( form: DxSchedulerTypes.AppointmentFormOpeningEvent['form'], @@ -100,11 +104,15 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, this.isSaved = false; - popup.on('hiding', () => { + if (this.hidingHandler) { + popup.off('hiding', this.hidingHandler); + } + this.hidingHandler = () => { if (!this.isSaved) { this.onCanceled(form, appointmentData); } - }); + }; + popup.on('hiding', this.hidingHandler); } } @@ -122,6 +130,7 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, import { DxScheduler, type DxSchedulerTypes } from 'devextreme-vue/scheduler'; const isSaved = ref(false); + let hidingHandler: (() => void) | null = null; function onCanceled( form: DxSchedulerTypes.AppointmentFormOpeningEvent['form'], @@ -153,11 +162,15 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, isSaved.value = false; - popup.on('hiding', () => { + if (hidingHandler) { + popup.off('hiding', hidingHandler); + } + hidingHandler = () => { if (!isSaved.value) { onCanceled(form, appointmentData); } - }); + }; + popup.on('hiding', hidingHandler); } @@ -196,6 +209,7 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, function App() { const isSaved = useRef(false); + const hidingHandlerRef = useRef<(() => void) | null>(null); const handleAppointmentFormOpening = useCallback( (e: SchedulerTypes.AppointmentFormOpeningEvent) => { @@ -204,9 +218,13 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, isSaved.current = false; - popup.on('hiding', () => { + if (hidingHandlerRef.current) { + popup.off('hiding', hidingHandlerRef.current); + } + hidingHandlerRef.current = () => { onCanceled(form, appointmentData, isSaved); - }); + }; + popup.on('hiding', hidingHandlerRef.current); }, [], ); @@ -222,8 +240,6 @@ The Scheduler does not expose a dedicated cancel event. To detect cancellation, --- -[note] Remove any previously registered `hiding` handler before registering a new one to prevent duplicate draft saves when the same appointment is opened multiple times. - ### Restore a Draft and Add a Discard Changes Button When the edit form opens, check `localStorage` for a saved draft. If a draft exists, restore its values into the form with `form.option('formData', …)` and add a **Discard Changes** button so the user can revert to the appointment's last saved state: @@ -516,7 +532,6 @@ When the edit form opens, check `localStorage` for a saved draft. If a draft exi --- - ### Clear the Draft After a Successful Save Clear the draft from `localStorage` after the user saves the appointment to avoid restoring stale data on subsequent opens: diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md index 16c23076fa..37d81889d5 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md @@ -144,7 +144,7 @@ Two details are critical for the recurrence panel to work correctly: ##### React - import { Scheduler, Editing, Form, Item } from 'devextreme-react/scheduler'; + import { Scheduler, Editing, Form } from 'devextreme-react/scheduler'; const legacyFormItems = [ { From 59c1e3703a959815c86a5678d39d41ddc825e41b Mon Sep 17 00:00:00 2001 From: Vlada Skorokhodova Date: Thu, 14 May 2026 18:20:13 +0400 Subject: [PATCH 5/5] add links --- .../070 Appointment Edit Form/20 Preserve Unsaved Changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md index 8e4c95be27..f75432e953 100644 --- a/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md +++ b/concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md @@ -4,7 +4,7 @@ Unsaved changes are lost when a user cancels the appointment edit form. To preve ### Save a Draft on Cancel -The Scheduler does not expose a dedicated cancel event. To detect cancellation, listen to the Popup's `hiding` event inside `onAppointmentFormOpening`. Guard with an `isSaved` flag so that the handler fires only when the user closes the form without saving: +The Scheduler does not expose a dedicated cancel event. To detect cancellation, listen to the Popup's [hiding](https://js.devexpress.com/Documentation/ApiReference/UI_Components/dxPopup/Configuration/#onHiding) event inside [onAppointmentFormOpening](/Documentation/ApiReference/UI_Components/dxScheduler/Configuration/#onAppointmentFormOpening). Guard with an `isSaved` flag so that the handler fires only when the user closes the form without saving: --- ##### jQuery