I am implementing a “Save as Draft” feature that allows users to save a snapshot of the current document to a separate doctype called Draft Entry
. This allows users to restore the draft later to continue working from where they left off, especially useful for documents with many fields and complex dependencies.
Below is my DraftEntryManager
class, which is responsible for managing the draft creation and restoration for any doctype.
DraftEntryManager Class (JavaScript)
medad.ui.form.DraftEntryManager = class DraftEntryManager {
constructor(opts) {
Object.assign(this, opts);
this.exclude_keys = [
'doctype',
'name',
'creation',
'modified',
'docstatus',
'__islocal',
'__unsaved',
'owner',
'modified_by',
'draft_entry'
];
}
restore_draft_data(data) {
Object.keys(data).forEach((key) => {
if (!this.exclude_keys.includes(key)) {
try {
this.frm.set_value(key, data[key]);
} catch (error) {
console.warn(`Could not set value for field ${key}:`, error);
}
}
});
}
make_dialog() {
const self = this;
return new frappe.ui.form.MultiSelectDialog({
doctype: 'Draft Entry',
target: this.frm,
columns: ['name', 'ref_doctype', 'creation', 'modified', 'owner', 'status'],
setters: {
ref_doctype: this.frm.doctype,
status: 'Draft'
},
add_filters_group: 1,
get_query: function () {
return {
query: 'medad_iep.medad_iep.doctype.draft_entry.draft_entry.get_draft_entries',
filters: {
ref_doctype: self.frm.doctype
}
};
},
make() {
let title = __('Select {0}', [this.for_select ? __('value') : __(this.doctype)]);
this.dialog = new frappe.ui.Dialog({
title: title,
fields: this.fields,
size: this.size,
primary_action_label: this.primary_action_label || __('Get Draft Entry'),
primary_action: () => {
let filters_data = this.get_custom_filters();
const data_values = cur_dialog.get_values();
const filtered_children = this.get_selected_child_names();
const selected_documents = [...this.get_checked_values(), ...this.get_parent_name_of_selected_children()];
this.action(selected_documents, {
...this.args,
...data_values,
...filters_data,
filtered_children
});
}
});
if (this.add_filters_group) {
this.make_filter_area();
}
this.args = {};
this.setup_results();
this.bind_events();
this.get_results();
this.dialog.show();
},
action: function (selections) {
if (selections.length === 0) {
frappe.msgprint(__('No draft entry selected'));
return;
} else if (selections.length > 1) {
frappe.throw(__('Only one draft entry can be selected'));
} else {
frappe.call({
method: 'medad_iep.api.medad_iep.draft_entry.get_draft_entry_by_name',
type: 'GET',
args: {
name: selections[0]
},
callback: function (r) {
if (r.message) {
if (r.message.data) {
if (r.message.ref_doctype && r.message.ref_doctype !== self.frm.doctype) {
frappe.throw(__('The selected draft entry is not for the current document type'));
}
const data = JSON.parse(r.message.data);
self.restore_draft_data(data);
self.frm.doc.draft_entry = selections[0];
}
cur_dialog.hide();
self.frm.refresh();
}
}
});
}
}
});
}
show() {
this.make_dialog();
}
};
medad.ui.form.MedadFormMixin = {
init: function () {
this.draft_entry_manager = new medad.ui.form.DraftEntryManager({
frm: this
});
},
setup_draft_entry: function () {
if (this.is_new()) {
this.init();
this.add_draft_entries_button();
this.add_save_as_draft_entry_button();
}
},
add_save_as_draft_entry_button: function () {
if (this.is_new()) {
this.add_custom_button(__('Save as Draft'), () => {
this.validate_draft_entry();
}).attr('class', 'btn btn-success ellipsis');
}
},
add_draft_entries_button: function () {
if (this.is_new()) {
this.add_custom_button(
__('{0} Draft Entries', [this.doctype]),
() => {
this.draft_entry_manager.show();
},
null,
'warning'
);
}
},
validate_draft_entry: function () {
const self = this;
if (!this.is_new()) {
return;
}
return new Promise((resolve, reject) => {
const form_data = self.get_values();
if (!self.doc.draft_entry) {
frappe.call({
method: 'medad_iep.api.medad_iep.draft_entry.create_draft_entry',
args: {
doctype: self.doctype,
data: JSON.stringify(form_data)
},
callback: function (r) {
if (r.message) {
self.doc.draft_entry = r.message;
frappe.show_alert({
message: __('Draft entry created successfully'),
indicator: 'green'
});
resolve(true);
} else {
frappe.show_alert({
message: __('Failed to create draft entry'),
indicator: 'red'
});
reject(new Error('Failed to create draft entry'));
}
}
});
} else {
frappe.call({
method: 'medad_iep.api.medad_iep.draft_entry.update_draft_entry',
type: 'PUT',
args: {
name: self.doc.draft_entry,
data: JSON.stringify(form_data)
},
callback: function (r) {
if (r.message) {
frappe.show_alert({
message: __('Draft entry updated successfully'),
indicator: 'green'
});
resolve(true);
} else {
frappe.show_alert({
message: __('Failed to update draft entry'),
indicator: 'red'
});
reject(new Error('Failed to update draft entry'));
}
}
});
}
});
},
get_values: function () {
const exclude_keys = [
'doctype',
'name',
'creation',
'modified',
'docstatus',
'__islocal',
'__unsaved',
'owner',
'modified_by',
'draft_entry'
];
const values = {};
const doc = this.doc;
Object.keys(doc).forEach((key) => {
if (!exclude_keys.includes(key)) {
values[key] = doc[key];
}
});
return values;
}
};
Usage Example in Doctype:
To integrate it into any doctype, simply call the setup_draft_entry
method on refresh
:
refresh: function (frm) {
frm.setup_draft_entry();
}
The Issue:
The main problem I am facing is related to onchange
triggers when restoring the draft data. For instance, if I have the following data:
{
"a_field": "A Value",
"b_field": "B Value",
"c_field": "C Value"
}
If c_field
is dependent on b_field
, any change in b_field
normally triggers a recalculation or event that sets or clears c_field
. However, when restoring draft data using set_value
, these onchange
triggers fire, causing the values not to be restored properly.
Desired Behavior:
I want to:
- Prevent
onchange
events from firing during the restoration of draft data. - Restore the snapshot seamlessly, even if there are dependencies between fields.
Proposed Solution:
I’m looking for the best way to:
- Temporarily disable
onchange
triggers during the draft restoration process. - Restore them once the data is completely populated.
- Ensure field dependencies are respected only after restoration is fully done.
Any suggestions or best practices on how to professionally handle this scenario in Frappe?