feat: Undo/Redo changes on FormViewversion-14
@@ -6,6 +6,7 @@ context('Form', () => { | |||
return frappe.call("frappe.tests.ui_test_helpers.create_contact_records"); | |||
}); | |||
}); | |||
it('create a new form', () => { | |||
cy.visit('/app/todo/new'); | |||
cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200); | |||
@@ -95,4 +96,63 @@ context('Form', () => { | |||
}) | |||
}) | |||
}); | |||
it('let user undo/redo field value changes', { scrollBehavior: false }, () => { | |||
const jump_to_field = (field_label) => { | |||
cy.get("body") | |||
.type("{esc}") // lose focus if any | |||
.type("{ctrl+j}") // jump to field | |||
.type(field_label) | |||
.wait(500) | |||
.type("{enter}") | |||
.wait(200) | |||
.type("{enter}") | |||
.wait(500); | |||
}; | |||
const type_value = (value) => { | |||
cy.focused() | |||
.clear() | |||
.type(value) | |||
.type("{esc}"); | |||
}; | |||
const undo = () => cy.get("body").type("{esc}").type("{ctrl+z}").wait(500); | |||
const redo = () => cy.get("body").type("{esc}").type("{ctrl+y}").wait(500); | |||
cy.new_form('User'); | |||
jump_to_field("Email"); | |||
type_value("admin@example.com"); | |||
jump_to_field("Username"); | |||
type_value("admin42"); | |||
jump_to_field("Birth Date"); | |||
type_value("12-31-01"); | |||
jump_to_field("Send Welcome Email"); | |||
cy.focused().uncheck() | |||
// make a mistake | |||
jump_to_field("Username"); | |||
type_value("admin24"); | |||
// undo behaviour | |||
undo(); | |||
cy.get_field("username").should('have.value', 'admin42'); | |||
// redo behaviour | |||
redo(); | |||
cy.get_field("username").should('have.value', 'admin24'); | |||
// undo everything & redo everything, ensure same values at the end | |||
undo(); undo(); undo(); undo(); undo(); | |||
redo(); redo(); redo(); redo(); redo(); | |||
cy.get_field("username").should('have.value', 'admin24'); | |||
cy.get_field("email").should('have.value', 'admin@example.com'); | |||
cy.get_field("birth_date").should('have.value', '12-31-2001'); // parsed value | |||
cy.get_field("send_welcome_email").should('not.be.checked'); | |||
}); | |||
}); |
@@ -187,6 +187,15 @@ frappe.ui.form.Control = class BaseControl { | |||
return Promise.resolve(); | |||
} | |||
const old_value = this.get_model_value(); | |||
this.frm?.undo_manager?.record_change({ | |||
fieldname: me.df.fieldname, | |||
old_value, | |||
new_value: value, | |||
doctype: this.doctype, | |||
docname: this.docname, | |||
is_child: Boolean(this.doc?.parenttype) | |||
}); | |||
this.inside_change_event = true; | |||
function set(value) { | |||
me.inside_change_event = false; | |||
@@ -13,6 +13,7 @@ import './script_helpers'; | |||
import './sidebar/form_sidebar'; | |||
import './footer/footer'; | |||
import './form_tour'; | |||
import { UndoManager } from './undo_manager'; | |||
frappe.ui.form.Controller = class FormController { | |||
constructor(opts) { | |||
@@ -38,6 +39,7 @@ frappe.ui.form.Form = class FrappeForm { | |||
this.fetch_dict = {}; | |||
this.parent = parent; | |||
this.doctype_layout = frappe.get_doc('DocType Layout', doctype_layout_name); | |||
this.undo_manager = new UndoManager({frm: this}); | |||
this.setup_meta(doctype); | |||
this.beforeUnloadListener = (event) => { | |||
@@ -143,6 +145,26 @@ frappe.ui.form.Form = class FrappeForm { | |||
condition: () => !this.is_new() | |||
}); | |||
// Undo and redo | |||
frappe.ui.keys.add_shortcut({ | |||
shortcut: 'ctrl+z', | |||
action: () => this.undo_manager.undo(), | |||
page: this.page, | |||
description: __('Undo last action'), | |||
}); | |||
frappe.ui.keys.add_shortcut({ | |||
shortcut: 'shift+ctrl+z', | |||
action: () => this.undo_manager.redo(), | |||
page: this.page, | |||
description: __('Redo last action'), | |||
}); | |||
frappe.ui.keys.add_shortcut({ | |||
shortcut: 'ctrl+y', | |||
action: () => this.undo_manager.redo(), | |||
page: this.page, | |||
description: __('Redo last action'), | |||
}); | |||
let grid_shortcut_keys = [ | |||
{ | |||
'shortcut': 'Up Arrow', | |||
@@ -357,6 +379,8 @@ frappe.ui.form.Form = class FrappeForm { | |||
cur_frm = this; | |||
this.undo_manager.erase_history(); | |||
if(this.docname) { // document to show | |||
this.save_disabled = false; | |||
// set the doc | |||
@@ -1761,7 +1785,7 @@ frappe.ui.form.Form = class FrappeForm { | |||
return sum; | |||
} | |||
scroll_to_field(fieldname) { | |||
scroll_to_field(fieldname, focus=true) { | |||
let field = this.get_field(fieldname); | |||
if (!field) return; | |||
@@ -1781,7 +1805,9 @@ frappe.ui.form.Form = class FrappeForm { | |||
frappe.utils.scroll_to($el, true, 15); | |||
// focus if text field | |||
$el.find('input, select, textarea').focus(); | |||
if (focus) { | |||
$el.find('input, select, textarea').focus(); | |||
} | |||
// highlight control inside field | |||
let control_element = $el.find('.form-control') | |||
@@ -0,0 +1,81 @@ | |||
export class UndoManager { | |||
constructor({ frm }) { | |||
this.frm = frm; | |||
this.undo_stack = []; | |||
this.redo_stack = []; | |||
} | |||
record_change({ | |||
fieldname, | |||
old_value, | |||
new_value, | |||
doctype, | |||
docname, | |||
is_child, | |||
}) { | |||
if (old_value == new_value) { | |||
return; | |||
} | |||
this.undo_stack.push({ | |||
fieldname, | |||
old_value, | |||
new_value, | |||
doctype, | |||
docname, | |||
is_child, | |||
}); | |||
} | |||
erase_history() { | |||
this.undo_stack = []; | |||
this.redo_stack = []; | |||
} | |||
undo() { | |||
const change = this.undo_stack.pop(); | |||
if (change) { | |||
this._apply_change(change); | |||
this._push_reverse_entry(change, this.redo_stack); | |||
} else { | |||
this._show_alert(__("Nothing left to undo")); | |||
} | |||
} | |||
redo() { | |||
const change = this.redo_stack.pop(); | |||
if (change) { | |||
this._apply_change(change); | |||
this._push_reverse_entry(change, this.undo_stack); | |||
} else { | |||
this._show_alert(__("Nothing left to redo")); | |||
} | |||
} | |||
_push_reverse_entry(change, stack) { | |||
stack.push({ | |||
...change, | |||
new_value: change.old_value, | |||
old_value: change.new_value, | |||
}); | |||
} | |||
_apply_change(change) { | |||
if (change.is_child) { | |||
frappe.model.set_value( | |||
change.doctype, | |||
change.docname, | |||
change.fieldname, | |||
change.old_value | |||
); | |||
} else { | |||
this.frm.set_value(change.fieldname, change.old_value); | |||
this.frm.scroll_to_field(change.fieldname, false); | |||
} | |||
} | |||
_show_alert(msg) { | |||
// reduce duration | |||
// keyboard interactions shouldn't have long running annoying toasts | |||
frappe.show_alert(msg, 3); | |||
} | |||
} |