diff --git a/app/assets/javascripts/admin/order_cycles/controllers/order_cycle_exchanges_controller.js.coffee b/app/assets/javascripts/admin/order_cycles/controllers/order_cycle_exchanges_controller.js.coffee index ee7c1aa5cf..db2cb94213 100644 --- a/app/assets/javascripts/admin/order_cycles/controllers/order_cycle_exchanges_controller.js.coffee +++ b/app/assets/javascripts/admin/order_cycles/controllers/order_cycle_exchanges_controller.js.coffee @@ -35,7 +35,11 @@ angular.module('admin.orderCycles') OrderCycle.removeExchangeFee(exchange, index) $scope.order_cycle_form.$dirty = true - $scope.setPickupTimeFieldDirty = (index) -> + $scope.setPickupTimeFieldDirty = (index, pickup_time) -> + # if the pickup_time is already set we are in edit mode, so no need to set pickup_time field as dirty + # to show it is required (it has a red border when set to dirty) + return if pickup_time + $timeout -> pickup_time_field_name = "order_cycle_outgoing_exchange_" + index + "_pickup_time" $scope.order_cycle_form[pickup_time_field_name].$setDirty() diff --git a/app/views/admin/order_cycles/_exchange_form.html.haml b/app/views/admin/order_cycles/_exchange_form.html.haml index e5aa662c60..d92099cd8c 100644 --- a/app/views/admin/order_cycles/_exchange_form.html.haml +++ b/app/views/admin/order_cycles/_exchange_form.html.haml @@ -10,7 +10,7 @@ %td.tags.panel-toggle.text-center{ name: "tags", ng: { if: 'enterprises[exchange.enterprise_id].managed || order_cycle.viewing_as_coordinator' } } {{ exchange.tags.length }} %td.collection-details - = text_field_tag 'order_cycle_outgoing_exchange_{{ $index }}_pickup_time', '', 'ng-init' => 'setPickupTimeFieldDirty($index)', 'id' => 'order_cycle_outgoing_exchange_{{ $index }}_pickup_time', 'required' => 'required', 'placeholder' => t('.pickup_time_placeholder'), 'ng-model' => 'exchange.pickup_time', 'ng-disabled' => '!enterprises[exchange.enterprise_id].managed && !order_cycle.viewing_as_coordinator', 'maxlength' => 35 + = text_field_tag 'order_cycle_outgoing_exchange_{{ $index }}_pickup_time', '', 'ng-init' => 'setPickupTimeFieldDirty($index, exchange.pickup_time)', 'id' => 'order_cycle_outgoing_exchange_{{ $index }}_pickup_time', 'required' => 'required', 'placeholder' => t('.pickup_time_placeholder'), 'ng-model' => 'exchange.pickup_time', 'ng-disabled' => '!enterprises[exchange.enterprise_id].managed && !order_cycle.viewing_as_coordinator', 'maxlength' => 35 %span.icon-question-sign{'ofn-with-tip' => t('.pickup_time_tip')} %br/ = text_field_tag 'order_cycle_outgoing_exchange_{{ $index }}_pickup_instructions', '', 'id' => 'order_cycle_outgoing_exchange_{{ $index }}_pickup_instructions', 'placeholder' => t('.pickup_instructions_placeholder'), 'ng-model' => 'exchange.pickup_instructions', 'ng-disabled' => '!enterprises[exchange.enterprise_id].managed && !order_cycle.viewing_as_coordinator' diff --git a/app/views/admin/order_cycles/checkout_options.html.haml b/app/views/admin/order_cycles/checkout_options.html.haml index 42c3213301..80d58be8c6 100644 --- a/app/views/admin/order_cycles/checkout_options.html.haml +++ b/app/views/admin/order_cycles/checkout_options.html.haml @@ -3,8 +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' %fieldset.no-border-bottom diff --git a/app/webpacker/controllers/unsaved_changes_controller.js b/app/webpacker/controllers/unsaved_changes_controller.js new file mode 100644 index 0000000000..afdb7ea8eb --- /dev/null +++ b/app/webpacker/controllers/unsaved_changes_controller.js @@ -0,0 +1,122 @@ +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 event trigger ie : +//
+// +// Optional, you can add 'data-unsaved-changes-disable-submit-button="true"' if you want to disable all +// submit buttons when the form hasn't been interacted with +// +export default class extends Controller { + connect() { + // add onChange event to all form element + this.element + .querySelectorAll("input, select, textarea") + .forEach((input) => { + input.addEventListener("change", this.formIsChanged.bind(this)); + }); + + this.element.addEventListener("submit", this.handleSubmit.bind(this)); + + // disable submit button when first loading the page + if (!this.isFormChanged() && this.isSubmitButtonDisabled()) { + this.disableButtons(); + } + } + + formIsChanged(event) { + // We only do something if the form hasn't already been changed + if (!this.isFormChanged()) { + this.setChanged("true"); + + if (this.isSubmitButtonDisabled()) { + this.enableButtons(); + } + } + } + + 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; + } + } + } + + handleSubmit(event) { + // if we are submitting the form, we don't want to trigger a warning so set changed to false + this.setChanged("false"); + } + + setChanged(changed) { + this.data.set("changed", changed); + } + + isFormChanged() { + return this.data.get("changed") == "true"; + } + + isSubmitButtonDisabled() { + if (this.data.has("disable-submit-button")) { + return this.data.get("disable-submit-button") == "true"; + } + + return false; + } + + enableButtons() { + this.submitButtons().forEach((button) => { + button.disabled = false; + }); + } + + disableButtons() { + this.submitButtons().forEach((button) => { + button.disabled = true; + }); + } + + submitButtons() { + return this.element.querySelectorAll("input[type='submit']"); + } +} 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..9379c3745d --- /dev/null +++ b/spec/javascripts/stimulus/unsaved_changes_controller_test.js @@ -0,0 +1,232 @@ +/** + * @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("#connect", () => { + describe("when disable-submit-button is true", () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + +
+ ` + }) + + it("disables any submit button", () => { + const submit = document.getElementById("test-submit") + + expect(submit.disabled).toBe(true) + }) + }) + + describe("when disable-submit-button is false", () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + +
+ ` + }) + + it("doesn't disable any submit button", () => { + const submit = document.getElementById("test-submit") + + expect(submit.disabled).toBe(false) + }) + }) + + describe("when disable-submit-button is not set", () => { + it("doesn't disable any submit button", () => { + const submit = document.getElementById("test-submit") + + expect(submit.disabled).toBe(false) + }) + }) + }) + + describe("#formIsChanged", () => { + let checkbox + let submit + + beforeEach(() => { + checkbox = document.getElementById("test-checkbox") + submit = document.getElementById("test-submit") + }) + + it("changed is set to true", () => { + const form = document.getElementById("test-form") + + checkbox.click() + + expect(form.dataset.unsavedChangesChanged).toBe("true") + }) + + describe("when disable-submit-button is true", () => { + it("enables any submit button", () => { + checkbox.click() + + expect(submit.disabled).toBe(false) + }) + }) + + describe("when disable-submit-button is false", () => { + it("does nothing", () => { + expect(submit.disabled).toBe(false) + + checkbox.click() + + expect(submit.disabled).toBe(false) + }) + }) + }) + + describe('#leavingPage', () => { + let checkbox + + beforeEach(() => { + // Add a mock I18n object to + const mockedT = jest.fn() + mockedT.mockImplementation((string) => (string)) + + global.I18n = { + t: mockedT + } + + checkbox = document.getElementById("test-checkbox") + }) + + 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", () => { + // 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', () => { + let confirmSpy + + beforeEach(() => { + confirmSpy = jest.spyOn(window, 'confirm') + }) + + afterEach(() => { + // cleanup + confirmSpy.mockRestore() + }) + + it("triggers a confirm popup up when leaving page and form has been interacted with", () => { + 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", () => { + // 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() + }) + }) + }) + + describe('#handleSubmit', () => { + let checkbox + + beforeEach(() => { + // Add a mock I18n object to + const mockedT = jest.fn() + mockedT.mockImplementation((string) => (string)) + + global.I18n = { + t: mockedT + } + + checkbox = document.getElementById("test-checkbox") + }) + + afterEach(() => { + delete global.I18n + }) + + describe('when submiting the form', () => { + it("changed is set to true", () => { + const form = document.getElementById("test-form") + + // interact with the form + checkbox.click() + + // submit the form + const submitEvent = new Event("submit") + form.dispatchEvent(submitEvent) + + expect(form.dataset.unsavedChangesChanged).toBe("false") + }) + }) + }) +})