diff --git a/addons/web/static/src/legacy/js/core/dialog.js b/addons/web/static/src/legacy/js/core/dialog.js index 2232225433a..823fc317632 100644 --- a/addons/web/static/src/legacy/js/core/dialog.js +++ b/addons/web/static/src/legacy/js/core/dialog.js @@ -427,17 +427,43 @@ Dialog.alert = function (owner, message, options) { // static method to open simple confirm dialog Dialog.confirm = function (owner, message, options) { + /** + * Creates an improved callback from the given callback value at the given + * key from the parent function's options parameter. This is improved to: + * + * - Prevent calling given callbacks once one has been called. + * + * - Re-allow calling callbacks once a previous callback call's returned + * Promise is rejected. + */ + let isBlocked = false; + function makeCallback(key) { + const callback = options && options[key]; + return function () { + if (isBlocked) { + // Do not (re)call any callback and return a rejected Promise + // to prevent closing the Dialog. + return Promise.reject(); + } + isBlocked = true; + const callbackRes = callback && callback.apply(this, arguments); + Promise.resolve(callbackRes).guardedCatch(() => { + isBlocked = false; + }); + return callbackRes; + }; + } var buttons = [ { text: _t("Ok"), classes: 'btn-primary', close: true, - click: options && options.confirm_callback, + click: makeCallback('confirm_callback'), }, { text: _t("Cancel"), close: true, - click: options && options.cancel_callback + click: makeCallback('cancel_callback'), } ]; return new Dialog(owner, _.extend({ diff --git a/addons/web/static/tests/legacy/core/dialog_tests.js b/addons/web/static/tests/legacy/core/dialog_tests.js index 3c83b2ba3b0..d52b0494781 100644 --- a/addons/web/static/tests/legacy/core/dialog_tests.js +++ b/addons/web/static/tests/legacy/core/dialog_tests.js @@ -103,6 +103,159 @@ QUnit.module('core', {}, function () { parent.destroy(); }); + QUnit.test("click twice on 'Ok' button of a confirm dialog", async function (assert) { + assert.expect(5); + + var testPromise = testUtils.makeTestPromise(); + var parent = await createEmptyParent(); + var options = { + confirm_callback: () => { + assert.step("confirm"); + return testPromise; + }, + }; + Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + assert.verifySteps([]); + + await testUtils.dom.click($('.modal[role="dialog"] .btn-primary')); + await testUtils.dom.click($('.modal[role="dialog"] .btn-primary')); + await testUtils.nextTick(); + assert.verifySteps(['confirm']); + assert.ok($('.modal[role="dialog"]').hasClass('show'), "Should still be opened"); + testPromise.resolve(); + await testUtils.nextTick(); + assert.notOk($('.modal[role="dialog"]').hasClass('show'), "Should now be closed"); + + parent.destroy(); + }); + + QUnit.test("click on 'Cancel' and then 'Ok' in a confirm dialog", async function (assert) { + assert.expect(3); + + var parent = await createEmptyParent(); + var options = { + confirm_callback: () => { + throw new Error("should not be called"); + }, + cancel_callback: () => { + assert.step("cancel"); + } + }; + Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + assert.verifySteps([]); + + testUtils.dom.click($('.modal[role="dialog"] footer button:not(.btn-primary)')); + testUtils.dom.click($('.modal[role="dialog"] footer .btn-primary')); + assert.verifySteps(['cancel']); + + parent.destroy(); + }); + + QUnit.test("click on 'Cancel' and then 'Ok' in a confirm dialog (no cancel callback)", async function (assert) { + assert.expect(2); + + var parent = await createEmptyParent(); + var options = { + confirm_callback: () => { + throw new Error("should not be called"); + }, + // Cannot add a step in cancel_callback, that's the point of this + // test, we'll rely on checking the Dialog is opened then closed + // without a crash. + }; + Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + assert.ok($('.modal[role="dialog"]').hasClass('show')); + testUtils.dom.click($('.modal[role="dialog"] footer button:not(.btn-primary)')); + testUtils.dom.click($('.modal[role="dialog"] footer .btn-primary')); + await testUtils.nextTick(); + assert.notOk($('.modal[role="dialog"]').hasClass('show')); + + parent.destroy(); + }); + + QUnit.test("Confirm dialog callbacks properly handle rejections", async function (assert) { + assert.expect(5); + + var parent = await createEmptyParent(); + var options = { + confirm_callback: () => { + assert.step("confirm"); + return Promise.reject(); + }, + cancel_callback: () => { + assert.step("cancel"); + return $.Deferred().reject(); // Test jquery deferred too + } + }; + Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + assert.verifySteps([]); + testUtils.dom.click($('.modal[role="dialog"] footer button:not(.btn-primary)')); + await testUtils.nextTick(); + testUtils.dom.click($('.modal[role="dialog"] footer .btn-primary')); + await testUtils.nextTick(); + testUtils.dom.click($('.modal[role="dialog"] footer button:not(.btn-primary)')); + assert.verifySteps(['cancel', 'confirm', 'cancel']); + + parent.destroy(); + }); + + QUnit.test("Properly can rely on the this in confirm and cancel callbacks of confirm dialog", async function (assert) { + assert.expect(2); + + let dialogInstance = null; + var parent = await createEmptyParent(); + var options = { + confirm_callback: function () { + assert.equal(this, dialogInstance, "'this' is properly a reference to the dialog instance"); + return Promise.reject(); + }, + cancel_callback: function () { + assert.equal(this, dialogInstance, "'this' is properly a reference to the dialog instance"); + return Promise.reject(); + } + }; + dialogInstance = Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + testUtils.dom.click($('.modal[role="dialog"] footer button:not(.btn-primary)')); + await testUtils.nextTick(); + testUtils.dom.click($('.modal[role="dialog"] footer .btn-primary')); + + parent.destroy(); + }); + + QUnit.test("Confirm dialog callbacks can return anything without crash", async function (assert) { + assert.expect(3); + // Note that this test could be removed in master if the related code + // is reworked. This only prevents a stable fix to break this again by + // relying on the fact what is returned by those callbacks are undefined + // or promises. + + var parent = await createEmptyParent(); + var options = { + confirm_callback: () => { + assert.step("confirm"); + return 5; + }, + }; + Dialog.confirm(parent, "", options); + await testUtils.nextTick(); + + assert.verifySteps([]); + testUtils.dom.click($('.modal[role="dialog"] footer .btn-primary')); + assert.verifySteps(['confirm']); + + parent.destroy(); + }); + QUnit.test("Closing alert dialog without using buttons calls confirm callback", async function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/legacy/views/form_tests.js b/addons/web/static/tests/legacy/views/form_tests.js index ecfa8a06e79..a411951131e 100644 --- a/addons/web/static/tests/legacy/views/form_tests.js +++ b/addons/web/static/tests/legacy/views/form_tests.js @@ -7682,6 +7682,44 @@ QUnit.module('LegacyViews', { form.destroy(); }); + QUnit.test('buttons with "confirm" attribute: click twice on "Ok"', async function (assert) { + assert.expect(7); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` +
`, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + intercepts: { + execute_action: function (event) { + assert.step('execute_action'); // should be called only once + event.data.on_success(); + }, + }, + }); + + assert.verifySteps(["onchange"]); + + await testUtils.dom.click(form.$('.o_statusbar_buttons button')); + assert.verifySteps([]); + + testUtils.dom.click($('.modal-footer button.btn-primary')); + await Promise.resolve(); + await testUtils.dom.click($('.modal-footer button.btn-primary')); + assert.verifySteps(['create', 'read', 'execute_action']); + + form.destroy(); + }); + QUnit.test('buttons are disabled until action is resolved (in dialogs)', async function (assert) { assert.expect(3);