diff --git a/app/components/avo/fields/trix_field/edit_component.html.erb b/app/components/avo/fields/trix_field/edit_component.html.erb index 01fdae224e..12d83525a4 100644 --- a/app/components/avo/fields/trix_field/edit_component.html.erb +++ b/app/components/avo/fields/trix_field/edit_component.html.erb @@ -19,7 +19,10 @@ <%= @form.text_area @field.id, value: @field.value.try(:to_trix_html) || @field.value, class: classes("w-full hidden"), - data: @field.get_html(:data, view: view, element: :input), + data: { + "trix-field-target": "textarea", + **@field.get_html(:data, view: view, element: :input) + }, disabled: disabled?, id: trix_id, placeholder: @field.placeholder, diff --git a/app/components/avo/views/resource_edit_component.html.erb b/app/components/avo/views/resource_edit_component.html.erb index 950e2b331f..976f50c1ae 100644 --- a/app/components/avo/views/resource_edit_component.html.erb +++ b/app/components/avo/views/resource_edit_component.html.erb @@ -17,6 +17,7 @@ html: { novalidate: true }, + data: { controller: "form-exit-prompt", form_exit_prompt_target: "form" }, multipart: true do |form| %> <%= render Avo::ReferrerParamsComponent.new back_path: back_path %> <%= content_tag :div, class: "space-y-12" do %> diff --git a/app/javascript/js/controllers.js b/app/javascript/js/controllers.js index ff30529b03..f29cacfab2 100644 --- a/app/javascript/js/controllers.js +++ b/app/javascript/js/controllers.js @@ -14,6 +14,7 @@ import DateFieldController from './controllers/fields/date_field_controller' import DateTimeFilterController from './controllers/date_time_filter_controller' import EasyMdeController from './controllers/fields/easy_mde_controller' import FilterController from './controllers/filter_controller' +import FormExitPromptController from './controllers/form_exit_prompt_controller' import HiddenInputController from './controllers/hidden_input_controller' import InputAutofocusController from './controllers/input_autofocus_controller' import ItemSelectAllController from './controllers/item_select_all_controller' @@ -56,6 +57,7 @@ application.register('copy-to-clipboard', CopyToClipboardController) application.register('dashboard-card', DashboardCardController) application.register('date-time-filter', DateTimeFilterController) application.register('filter', FilterController) +application.register('form-exit-prompt', FormExitPromptController) application.register('panel-refresh', PanelRefreshController) application.register('hidden-input', HiddenInputController) application.register('input-autofocus', InputAutofocusController) diff --git a/app/javascript/js/controllers/fields/code_field_controller.js b/app/javascript/js/controllers/fields/code_field_controller.js index c8bcfd5ffa..593a9c4e19 100644 --- a/app/javascript/js/controllers/fields/code_field_controller.js +++ b/app/javascript/js/controllers/fields/code_field_controller.js @@ -38,7 +38,7 @@ export default class extends Controller { CodeMirror.fromTextArea(this.elementTarget, options).on('change', (cm) => { // Add this innerText change and dispatch an event to allow stimulus to pick up the input event. vm.elementTarget.innerText = cm.getValue() - vm.elementTarget.dispatchEvent(new Event('input')) + vm.elementTarget.dispatchEvent(new Event('input', { bubbles: true })) }) }, 1) } diff --git a/app/javascript/js/controllers/fields/easy_mde_controller.js b/app/javascript/js/controllers/fields/easy_mde_controller.js index 3d504af39c..a49305a139 100644 --- a/app/javascript/js/controllers/fields/easy_mde_controller.js +++ b/app/javascript/js/controllers/fields/easy_mde_controller.js @@ -20,7 +20,7 @@ export default class extends Controller { const options = { element: this.elementTarget, spellChecker: this.componentOptions.spell_checker, - autoRefresh: { delay: 500}, + autoRefresh: { delay: 500 }, } if (this.view === 'show') { @@ -29,6 +29,12 @@ export default class extends Controller { } const easyMde = new EasyMDE(options) + + easyMde.codemirror.on('change', () => { + this.elementTarget.value = easyMde.value() + this.elementTarget.dispatchEvent(new Event('input', { bubbles: true })) + }) + if (this.view === 'show') { easyMde.codemirror.options.readOnly = true } diff --git a/app/javascript/js/controllers/fields/key_value_controller.js b/app/javascript/js/controllers/fields/key_value_controller.js index dfa0b05255..dfcf13aaff 100644 --- a/app/javascript/js/controllers/fields/key_value_controller.js +++ b/app/javascript/js/controllers/fields/key_value_controller.js @@ -73,7 +73,7 @@ export default class extends Controller { result = Object.assign(...this.fieldValue.map(([key, val]) => ({ [key]: val }))) } this.inputTarget.innerText = JSON.stringify(result) - this.inputTarget.dispatchEvent(new Event('input')) + this.inputTarget.dispatchEvent(new Event('input', { bubbles: true })) } updateKeyValueComponent() { diff --git a/app/javascript/js/controllers/fields/tiptap_field_controller.js b/app/javascript/js/controllers/fields/tiptap_field_controller.js index 82f5391965..b453b473ff 100644 --- a/app/javascript/js/controllers/fields/tiptap_field_controller.js +++ b/app/javascript/js/controllers/fields/tiptap_field_controller.js @@ -70,6 +70,8 @@ export default class extends Controller { onUpdate = () => { this.inputTarget.value = this.editor.getHTML() + + this.inputTarget.dispatchEvent(new Event('input', { bubbles: true })) } onSelectionUpdate = () => { diff --git a/app/javascript/js/controllers/fields/trix_field_controller.js b/app/javascript/js/controllers/fields/trix_field_controller.js index 45d776d818..12dd104bf0 100644 --- a/app/javascript/js/controllers/fields/trix_field_controller.js +++ b/app/javascript/js/controllers/fields/trix_field_controller.js @@ -6,7 +6,7 @@ import { Controller } from '@hotwired/stimulus' import { castBoolean } from '../../helpers/cast_boolean' export default class extends Controller { - static targets = ['editor', 'controller'] + static targets = ['editor', 'controller', 'textarea'] static values = { resourceName: String, @@ -33,6 +33,10 @@ export default class extends Controller { } connect() { + this.editorTarget.addEventListener('trix-change', () => { + this.textareaTarget.dispatchEvent(new Event('input', { bubbles: true })) + }) + if (this.attachmentsDisabledValue) { // Remove the attachments button this.controllerTarget.querySelector('.trix-button-group--file-tools').remove() diff --git a/app/javascript/js/controllers/form_exit_prompt_controller.js b/app/javascript/js/controllers/form_exit_prompt_controller.js new file mode 100644 index 0000000000..094c89e714 --- /dev/null +++ b/app/javascript/js/controllers/form_exit_prompt_controller.js @@ -0,0 +1,130 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['form'] + + connect() { + this.isDirty = false + this.isFormSubmitting = false + this.currentLocationUrl = window.location.href + + this.initialFormState = this.getFormState() + this.currentFormState = this.getFormState() + + // for select tags + this.formTarget.addEventListener('change', this.trackChanges.bind(this)) + // for all other input fields + this.formTarget.addEventListener('input', this.trackChanges.bind(this)) + + // in most cases this event will be triggered because Turbo prevents full page reload on navigation + window.addEventListener( + 'turbo:before-visit', + this.preventTurboNavigation.bind(this), + ) + window.addEventListener('beforeunload', this.preventFullPageNavigation.bind(this)) + + this.formTarget.addEventListener('turbo:submit-start', this.handleFormSubmitStart.bind(this)) + this.formTarget.addEventListener('turbo:submit-end', this.handleFormSubmitEnd.bind(this)) + } + + disconnect() { + window.removeEventListener( + 'turbo:before-visit', + this.preventTurboNavigation.bind(this), + ) + window.removeEventListener( + 'beforeunload', + this.preventFullPageNavigation.bind(this), + ) + } + + getFormState() { + const formState = {} + const formFieldsArray = [...this.formTarget.querySelectorAll('input, textarea, select')] + const formFieldsWithIdentifier = formFieldsArray.filter((item) => Boolean(item.id)) + + formFieldsWithIdentifier.forEach((item) => { + let { value } = item + + if (item.type === 'checkbox') { + value = item.checked + } + + formState[item.id] = value + }) + + return formState + } + + trackChanges(event) { + const { target: { id, type: fieldType, checked } } = event + + let { target: { value } } = event + + if (fieldType === 'checkbox') { + value = checked + } + + this.currentFormState[id] = value + } + + evaluateFormState() { + const isFormDirty = Object.keys(this.initialFormState).some((key) => this.initialFormState[key] !== this.currentFormState[key]) + // for key value fields which are not present in initial state for new form + const isNewFieldAdded = Object.keys(this.currentFormState).length > Object.keys(this.initialFormState).length + + this.isDirty = isFormDirty || isNewFieldAdded + } + + handleFormSubmitStart() { + this.isFormSubmitting = true + } + + handleFormSubmitEnd(event) { + if (event.detail.success) { + this.resetState() + } + } + + resetState() { + this.isDirty = false + this.isFormSubmitting = false + this.initialFormState = {} + this.currentFormState = {} + } + + handleDirtyFormNavigation(event) { + const message = 'Are you sure you want to navigate away from the page? You will lose all your changes.' + + if (window.confirm(message)) { + this.resetState() + } else { + event.preventDefault() + } + } + + preventTurboNavigation(event) { + // don't intercept if URL doesn't change e.g. modals OR when form is submitting + if (event.detail.url === this.currentLocationUrl || this.isFormSubmitting) { + return + } + + this.evaluateFormState() + + if (this.isDirty) { + this.handleDirtyFormNavigation(event) + } + } + + preventFullPageNavigation(event) { + this.evaluateFormState() + + if (this.isDirty) { + event.preventDefault() + + // for legacy browsers support + // see: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event + event.returnValue = 'Are you sure you want to navigate away from the page? You will lose all your changes.' + } + } +} diff --git a/spec/system/form_exit_prompt_spec.rb b/spec/system/form_exit_prompt_spec.rb new file mode 100644 index 0000000000..99312025c2 --- /dev/null +++ b/spec/system/form_exit_prompt_spec.rb @@ -0,0 +1,93 @@ +require "rails_helper" + +RSpec.describe "form_exit_prompt", type: :system do + let(:city) { create :city } + + context "when navigating away with unsaved changes" do + it "prompts the user with a confirmation message and prevents navigation if the user cancels" do + visit "/admin/resources/cities" + click_on "Create new city" + fill_in "city_name", with: city.name + + message = dismiss_prompt { click_on "Comments" } + + expect(message).to eq( + "Are you sure you want to navigate away from the page? You will lose all your changes." + ) + expect(page).to have_current_path("/admin/resources/cities/new") + end + + it "allows navigation if the user confirms" do + visit "/admin/resources/cities/#{city.id}" + click_on "Edit" + fill_in "city_name", with: "#{city.name} updated" + accept_prompt { click_on "Comments" } + + expect(page).to have_current_path("/admin/resources/comments") + end + end + + context "when submitting the form" do + it "does not prompt the user with a confirmation message" do + visit "/admin/resources/cities" + click_on "Create new city" + fill_in "city_name", with: "New City" + click_button "Save" + + expect(page).to have_current_path(%r{/admin/resources/cities/\w+$}) + end + end + + context "when navigating away without changing anything in the form" do + it "does not prompt the user with a confirmation message" do + visit "/admin/resources/cities" + click_on "Create new city" + click_on "Comments" + + expect(page).to have_current_path("/admin/resources/comments") + end + end + + context "when reloading the page" do + it "prompts the user with a confirmation message" do + visit "/admin/resources/cities" + click_on "Create new city" + fill_in "city_name", with: city.name + + message = accept_prompt { page.refresh } + + expect(message).to eq("") + end + end + + context "when form is reverted to clean state" do + it "does not prompt the user with a confirmation message" do + visit "/admin/resources/cities/#{city.id}" + click_on "Edit" + fill_in "city_name", with: "#{city.name} updated" + + message = dismiss_prompt { click_on "Cancel" } + + expect(message).to eq( + "Are you sure you want to navigate away from the page? You will lose all your changes." + ) + + fill_in "city_name", with: city.name + click_on "Cancel" + + expect(page).to have_current_path(%r{/admin/resources/cities/\w+$}) + end + end + + context "when opening modals" do + it "does not prompt the user with a confirmation message" do + visit "/admin/resources/cities/#{city.id}" + click_on "Edit" + fill_in "city_name", with: "#{city.name} updated" + click_on "Actions" + click_on "Dummy action city resource" + + expect(page).to have_current_path(%r{/admin/resources/cities/\w+/edit}) + end + end +end