From ef309c0fd04d242f408bb7eb9ff16dc2c615ee32 Mon Sep 17 00:00:00 2001 From: Gaetan Craig-Riou Date: Tue, 31 Jan 2023 15:01:55 +1100 Subject: [PATCH] Order cycle form, checkout options steps add user warning when leaving page and form has been changed Add UnsavedChanges stimulus controller, it should be generic enough so that it can reused somewhere else. It works with both 'beforeunload' event and 'turbolinks:before-visit' when using turbo links. --- .../order_cycles/checkout_options.html.haml | 10 +- .../controllers/unsaved_changes_controller.js | 54 +++++++++ .../unsaved_changes_controller_test.js | 109 ++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 app/webpacker/controllers/unsaved_changes_controller.js create mode 100644 spec/javascripts/stimulus/unsaved_changes_controller_test.js diff --git a/app/views/admin/order_cycles/checkout_options.html.haml b/app/views/admin/order_cycles/checkout_options.html.haml index 42c3213301..9138c14565 100644 --- a/app/views/admin/order_cycles/checkout_options.html.haml +++ b/app/views/admin/order_cycles/checkout_options.html.haml @@ -3,7 +3,7 @@ - content_for :page_title do = t :edit_order_cycle -= form_for [main_app, :admin, @order_cycle], html: { class: "order_cycle" } do |f| += form_for [main_app, :admin, @order_cycle], html: { class: "order_cycle" , data: { controller: 'unsaved-changes', action: 'beforeunload@window->unsaved-changes#leavingPage', 'unsaved-changes-changed': "false" } } do |f| = render 'wizard_progress' @@ -26,7 +26,7 @@ %td.text-center - if distributor_shipping_methods.many? %label - = check_box_tag nil, nil, nil, { "data-action": "change->select-all#toggleAll", "data-select-all-target": "all" } + = check_box_tag nil, nil, nil, { "data-action": "change->select-all#toggleAll change->unsaved-changes#formIsChanged", "data-select-all-target": "all" } = t(".select_all") %td %em= distributor.name @@ -37,7 +37,7 @@ distributor_shipping_method.id, @order_cycle.distributor_shipping_methods.include?(distributor_shipping_method), id: "order_cycle_selected_distributor_shipping_method_ids_#{distributor_shipping_method.id}", - data: ({ "action" => "change->select-all#toggleCheckbox", "select-all-target" => "checkbox" } if distributor_shipping_method.shipping_method.frontend?) + data: ({ "action" => "change->select-all#toggleCheckbox change->unsaved-changes#formIsChanged", "select-all-target" => "checkbox" } if distributor_shipping_method.shipping_method.frontend?) = distributor_shipping_method.shipping_method.name - distributor.shipping_methods.backend.each do |shipping_method| %label.disabled @@ -57,7 +57,7 @@ %td.text-center - if distributor_payment_methods.many? %label - = check_box_tag nil, nil, nil, { "data-action": "change->select-all#toggleAll", "data-select-all-target": "all" } + = check_box_tag nil, nil, nil, { "data-action": "change->select-all#toggleAll change->unsaved-changes#formIsChanged", "data-select-all-target": "all" } = t(".select_all") %td %em= distributor.name @@ -68,7 +68,7 @@ distributor_payment_method.id, @order_cycle.distributor_payment_methods.include?(distributor_payment_method), id: "order_cycle_selected_distributor_payment_method_ids_#{distributor_payment_method.id}", - data: ({ "action" => "change->select-all#toggleCheckbox", "select-all-target" => "checkbox" } if distributor_payment_method.payment_method.frontend?) + data: ({ "action" => "change->select-all#toggleCheckbox change->unsaved-changes#formIsChanged", "select-all-target" => "checkbox" } if distributor_payment_method.payment_method.frontend?) = distributor_payment_method.payment_method.name - distributor.payment_methods.inactive_or_backend.each do |payment_method| %label.disabled diff --git a/app/webpacker/controllers/unsaved_changes_controller.js b/app/webpacker/controllers/unsaved_changes_controller.js new file mode 100644 index 0000000000..fca6da9f45 --- /dev/null +++ b/app/webpacker/controllers/unsaved_changes_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "stimulus"; + +// UnsavedChanges allows you to promp the user about unsaved changes when trying to leave the page +// +// Usage : +// - with beforeunload event : +//
+// +//
+// +// - with turbolinks : +//
+// +//
+// +// You can also combine the two actions +// +export default class extends Controller { + formIsChanged(event) { + this.setChanged("true"); + } + + leavingPage(event) { + const LEAVING_PAGE_MESSAGE = I18n.t("admin.unsaved_confirm_leave"); + + if (this.isFormChanged()) { + if (event.type == "turbolinks:before-visit") { + if (!window.confirm(LEAVING_PAGE_MESSAGE)) { + event.preventDefault(); + } + } else { + // We cover our bases, according to the documentation we should be able to prompt the user + // by calling event.preventDefault(), but it's not really supported yet. + // Instead we set the value of event.returnValue, and return a string, both of them + // should prompt the user. + // Note, in most modern browser a generic string not under the control of the webpage is shown + // instead of the returned string. + // + // More info : https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event + // + event.returnValue = LEAVING_PAGE_MESSAGE; + return event.returnValue; + } + } + } + + setChanged(changed) { + this.data.set("changed", changed); + } + + isFormChanged() { + return this.data.get("changed") == "true"; + } +} diff --git a/spec/javascripts/stimulus/unsaved_changes_controller_test.js b/spec/javascripts/stimulus/unsaved_changes_controller_test.js new file mode 100644 index 0000000000..6b5ca0c707 --- /dev/null +++ b/spec/javascripts/stimulus/unsaved_changes_controller_test.js @@ -0,0 +1,109 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus" +import unsaved_changes_controller from "../../../app/webpacker/controllers/unsaved_changes_controller" + +describe("UnsavedChangesController", () => { + beforeAll(() => { + const application = Application.start() + application.register("unsaved-changes", unsaved_changes_controller) + }) + + beforeEach(() => { + document.body.innerHTML = ` +
+ +
+ ` + }) + + describe("#formIsChanged", () => { + it("changed is set to true", () => { + const form = document.getElementById("test-form") + const checkbox = document.getElementById("test-checkbox") + + checkbox.click() + + expect(form.dataset.unsavedChangesChanged).toBe('true') + + }) + }) + + describe('#leavingPage', () => { + beforeEach(() => { + // Add a mock I18n object to + const mockedT = jest.fn() + mockedT.mockImplementation((string) => (string)) + + global.I18n = { + t: mockedT + } + }) + + afterEach(() => { + delete global.I18n + }) + + describe('when triggering a beforeunload event', () => { + it("triggers leave page pop up when leaving page and form has been interacted with", () => { + const checkbox = document.getElementById("test-checkbox") + + // interact with the form + checkbox.click() + + // trigger beforeunload to simulate leaving the page + const beforeunloadEvent = new Event("beforeunload") + window.dispatchEvent(beforeunloadEvent) + + // Test the event returnValue has been set, we don't really care about the value as + // the brower will ignore it + expect(beforeunloadEvent.returnValue).toBeTruthy() + }) + }) + + describe('when triggering a turbolinks:before-visit event', () => { + it("triggers a confirm popup up when leaving page and form has been interacted with", () => { + const checkbox = document.getElementById("test-checkbox") + const confirmSpy = jest.spyOn(window, 'confirm') + confirmSpy.mockImplementation((msg) => {}) + + // interact with the form + checkbox.click() + + // trigger turbolinks:before-visit to simulate leaving the page + const turbolinkEv = new Event("turbolinks:before-visit") + window.dispatchEvent(turbolinkEv) + + expect(confirmSpy).toHaveBeenCalled() + + }) + + it("stays on the page if user clicks cancel on the confirm popup", () => { + const checkbox = document.getElementById("test-checkbox") + const confirmSpy = jest.spyOn(window, 'confirm') + // return false to simulate a user clicking on cancel + confirmSpy.mockImplementation((msg) => (false)) + + // interact with the form + checkbox.click() + + // trigger turbolinks:before-visit to simulate leaving the page + const turbolinkEv = new Event("turbolinks:before-visit") + const preventDefaultSpy = jest.spyOn(turbolinkEv, 'preventDefault') + + window.dispatchEvent(turbolinkEv) + + expect(confirmSpy).toHaveBeenCalled() + expect(preventDefaultSpy).toHaveBeenCalled() + + // cleanup + confirmSpy.mockRestore() + }) + }) + }) +})