Scheduler: update Appointment Edit Form how-to#8742
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Scheduler how-to topic and an accompanying embedded demo that shows how to preserve unsaved appointment edit form changes by saving drafts to localStorage, restoring them on reopen, offering a “Discard Changes” action, and clearing drafts after successful saves.
Changes:
- Added a new documentation topic describing draft save/restore/clear flows for jQuery, Angular, Vue, and React.
- Added a new embedded demo (HTML/JS/CSS) to illustrate the behavior in the documentation simulator.
- Implemented a “Discard Changes” button and popup
hidinghandling in the demo to persist drafts.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md | New how-to documentation for persisting unsaved appointment form edits via localStorage. |
| applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js | New demo logic for saving/restoring drafts, adding a discard button, and clearing drafts on save. |
| applications/UIWidgets/Guides/SchedulerPreserveChanges/index.html | Demo host markup for the Scheduler instance. |
| applications/UIWidgets/Guides/SchedulerPreserveChanges/index.css | Demo styling (Scheduler height and discard button item spacing/border). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:269
- After the user clicks Discard Changes, the draft is removed, but the cancel-detection logic (Popup
hidinghandler guarded only byisSaved) can still run when the popup closes and immediately save a new draft again. Consider setting a separate flag (or temporarily disabling the hiding handler) when changes are discarded so closing the form does not recreate the draft.
onClick: function () {
localStorage.removeItem('dx-scheduler-draft-' + (appointmentId ?? 'new'));
form.option('formData', Object.assign({}, form.option('formData'), {
text: originalData.text,
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ub.com/vladaskorohodova/devextreme-documentation into scheduler-preserve-unsaved-changes25_2 # Conflicts: # concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 25 comments.
Comments suppressed due to low confidence (16)
applications/UIWidgets/Guides/SchedulerPreserveChanges/index.js:219
onAppointmentUpdatingruns before the appointment is updated and the operation can still be canceled or fail. Clearing the draft and marking the form as saved here can lose the only copy of the user's changes; clear the draft only after a successful update.
onAppointmentUpdating: function (e) {
isSaved = true;
const appointmentId = e && e.oldData && e.oldData.id != null ? e.oldData.id : null;
clearDraft(appointmentId);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:572
onAppointmentAddingfires before the appointment is added and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the add is canceled or fails.
handleAppointmentAdding(): void {
this.isSaved = true;
localStorage.removeItem('dx-scheduler-draft-new');
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:578
onAppointmentUpdatingfires before the appointment is updated and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the update is canceled or fails.
handleAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void {
this.isSaved = true;
const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null;
localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:600
onAppointmentAddingfires before the appointment is added and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the add is canceled or fails.
function handleAppointmentAdding(): void {
isSaved.value = true;
localStorage.removeItem('dx-scheduler-draft-new');
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:606
onAppointmentUpdatingfires before the appointment is updated and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the update is canceled or fails.
function handleAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void {
isSaved.value = true;
const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null;
localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:621
onAppointmentAddingfires before the appointment is added and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the add is canceled or fails.
const handleAppointmentAdding = useCallback(() => {
isSaved.current = true;
localStorage.removeItem('dx-scheduler-draft-new');
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:628
onAppointmentUpdatingfires before the appointment is updated and can still be canceled, so this does not satisfy the section's "after a successful save" requirement. Clearing the draft here can lose unsaved changes if the update is canceled or fails.
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}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:578
- This removal uses the literal
nullkey whenoldData.idis missing, but drafts are saved and restored with the fallback keydx-scheduler-draft-new. For appointments without anid, the stale draft will remain and be restored again.
const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null;
localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:606
- This removal uses the literal
nullkey whenoldData.idis missing, but drafts are saved and restored with the fallback keydx-scheduler-draft-new. For appointments without anid, the stale draft will remain and be restored again.
const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null;
localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:628
- This removal uses the literal
nullkey whenoldData.idis missing, but drafts are saved and restored with the fallback keydx-scheduler-draft-new. For appointments without anid, the stale draft will remain and be restored again.
const appointmentId = e.oldData?.['id'] != null ? e.oldData['id'] : null;
localStorage.removeItem(`dx-scheduler-draft-${appointmentId}`);
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:350
- After this removes the draft, the cancel-detection handler from the previous section still has
isSaved === false. If the user closes the popup after discarding, thehidinghandler saves a new draft immediately, so the discarded changes reappear on the next open.
onClick: () => {
localStorage.removeItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`);
form.option('formData', {
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:426
- After this removes the draft, the cancel-detection handler from the previous section still has
isSaved === false. If the user closes the popup after discarding, thehidinghandler saves a new draft immediately, so the discarded changes reappear on the next open.
onClick: () => {
localStorage.removeItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`);
form.option('formData', {
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/20 Preserve Unsaved Changes.md:497
- After this removes the draft, the cancel-detection handler from the previous section still has
isSaved === false. If the user closes the popup after discarding, thehidinghandler saves a new draft immediately, so the discarded changes reappear on the next open.
onClick: () => {
localStorage.removeItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`);
form.option('formData', {
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md:470
- This subscribes a new
fieldDataChangedhandler every time the appointment form opens, but the Scheduler reuses the form instance. After several openings, each recurrence edit will run duplicated handlers; keep a handler reference and detach/replace it before subscribing again.
form.on('fieldDataChanged', (fe: { dataField: string; value: unknown }) => {
if (fe.dataField === 'recurrenceRule') {
form.option('formData.repeat', !!fe.value);
}
});
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md:529
- This subscribes a new
fieldDataChangedhandler every time the appointment form opens, but the Scheduler reuses the form instance. After several openings, each recurrence edit will run duplicated handlers; keep a handler reference and detach/replace it before subscribing again.
form.on('fieldDataChanged', (fe: { dataField: string; value: unknown }) => {
if (fe.dataField === 'recurrenceRule') {
form.option('formData.repeat', !!fe.value);
}
});
concepts/05 UI Components/Scheduler/030 Appointments/070 Appointment Edit Form/25 Recreate the Legacy Form Layout.md:582
- This subscribes a new
fieldDataChangedhandler every time the appointment form opens, but the Scheduler reuses the form instance. After several openings, each recurrence edit will run duplicated handlers; keep a handler reference and detach/replace it before subscribing again.
form.on('fieldDataChanged', (fe: { dataField: string; value: unknown }) => {
if (fe.dataField === 'recurrenceRule') {
form.option('formData.repeat', !!fe.value);
}
});
| 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; |
| const draft = { | ||
| text: formData.text, | ||
| description: formData.description, | ||
| startDate: formData.startDate instanceof Date | ||
| ? formData.startDate.toISOString() |
| const draft = { | ||
| text: formData['text'], | ||
| description: formData['description'], | ||
| startDate: formData['startDate'] instanceof Date | ||
| ? (formData['startDate'] as Date).toISOString() |
| const draft = { | ||
| text: formData['text'], | ||
| description: formData['description'], | ||
| startDate: formData['startDate'] instanceof Date | ||
| ? (formData['startDate'] as Date).toISOString() |
| const draft = { | ||
| text: formData['text'], | ||
| description: formData['description'], | ||
| startDate: formData['startDate'] instanceof Date | ||
| ? (formData['startDate'] as Date).toISOString() |
| const raw = localStorage.getItem(`dx-scheduler-draft-${appointmentId ?? 'new'}`); | ||
| if (raw) { | ||
| const draft = JSON.parse(raw) as Record<string, unknown>; | ||
| 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); |
|
|
||
| isSaved = false; | ||
|
|
||
| popup.off('hiding', hidingHandler); |
|
|
||
| ### 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: |
| form.on('fieldDataChanged', function (fe) { | ||
| if (fe.dataField === 'recurrenceRule') { | ||
| form.option('formData.repeat', !!fe.value); | ||
| } | ||
| }); | ||
|
|
| form.on('fieldDataChanged', function (fe) { | ||
| if (fe.dataField === 'recurrenceRule') { | ||
| form.option('formData.repeat', !!fe.value); | ||
| } | ||
| }); | ||
|
|
No description provided.