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() + }) + }) + }) +})