diff --git a/app/assets/javascripts/stories.js b/app/assets/javascripts/stories.js index 5e7da51b..df0f4438 100644 --- a/app/assets/javascripts/stories.js +++ b/app/assets/javascripts/stories.js @@ -1,4 +1,20 @@ -document.addEventListener("DOMContentLoaded", () => { +// Single source of truth for the unsaved-changes wording. It is shown for +// every in-app link the user might leave through. The native beforeunload +// prompt (reload / closing the tab) always uses the browser's own generic +// text; browsers ignore any custom string there, so that one case aside, this +// keeps the wording consistent everywhere we can control it. +const UNSAVED_CHANGES_MESSAGE = + "You have unsaved changes. Are you sure you want to leave?"; + +// Init on turbolinks:load (not DOMContentLoaded) so it also runs on Turbolinks +// visits, matching project.js. reloadable state lives at module scope so the +// delegated click handler can be added by reference and de-duplicated across +// visits instead of stacking a new listener each time. +let editForm = null; +let editFormInitialState = null; +let editFormDirty = false; + +document.addEventListener("turbolinks:load", () => { document.querySelectorAll("[data-has-preview]").forEach((element) => { let debounceTimer; @@ -25,8 +41,88 @@ document.addEventListener("DOMContentLoaded", () => { debounceTimer = window.setTimeout(updateMarkdown, 300); }); }); + + initUnsavedChangesGuard(); }); +function initUnsavedChangesGuard() { + editForm = document.querySelector(".edit_story"); + editFormDirty = false; + // Start each visit from a clean slate; drop any beforeunload guard carried + // over from a previous page. + addBeforeUnloadEventListener(false); + + if (!editForm) { + return; + } + + // Snapshot the initial form state so we can compare against it. Tracking a + // plain "changed at least once" flag reported the form as dirty even after + // the user undid their edits (e.g. typed some text and then deleted it). + editFormInitialState = serializeForm(editForm); + + // "input" covers text fields; "change" covers selects like the status. + editForm.addEventListener("input", refreshEditFormDirtyState); + editForm.addEventListener("change", refreshEditFormDirtyState); + editForm.addEventListener("submit", clearEditFormDirtyState); + + // Same warning for every in-app link that leaves the page (Back, the logo, + // Sign out, ...), not just a couple of buttons. Added by reference so it is + // de-duplicated if turbolinks:load fires again. + document.addEventListener("click", confirmLeaveIfUnsavedEdits); +} + +function refreshEditFormDirtyState() { + editFormDirty = serializeForm(editForm) !== editFormInitialState; + addBeforeUnloadEventListener(editFormDirty); +} + +function clearEditFormDirtyState() { + editFormDirty = false; + addBeforeUnloadEventListener(false); +} + +function confirmLeaveIfUnsavedEdits(event) { + if (!editFormDirty) { + return; + } + + const link = event.target.closest("a[href]"); + if (!link) { + return; + } + + // Links that do not actually leave the page (new tab, in-page anchors, + // javascript: handlers) should not trigger the warning. + const href = link.getAttribute("href") || ""; + if (link.target === "_blank" || href.startsWith("#") || href.startsWith("javascript:")) { + return; + } + + if (window.confirm(UNSAVED_CHANGES_MESSAGE)) { + clearEditFormDirtyState(); + } else { + event.preventDefault(); + } +} + +function serializeForm(form) { + return new URLSearchParams(new FormData(form)).toString(); +} + +function addBeforeUnloadEventListener(isDirty) { + if (isDirty) { + window.addEventListener("beforeunload", warnUserIfUnsavedEdits); + } else { + window.removeEventListener("beforeunload", warnUserIfUnsavedEdits); + } +} + +function warnUserIfUnsavedEdits(event) { + event.preventDefault(); + event.returnValue = UNSAVED_CHANGES_MESSAGE; +} + function updateStatusButton(color, status) { const button = document.querySelector(".story-title .dropdown-wrapper > button"); button.className = `button ${color}`; diff --git a/spec/features/projects_manage_spec.rb b/spec/features/projects_manage_spec.rb index 38e0e992..f223ba14 100644 --- a/spec/features/projects_manage_spec.rb +++ b/spec/features/projects_manage_spec.rb @@ -266,9 +266,9 @@ last_project = Project.parents.last expect(last_project.id).not_to eq(project.id) - expect(last_project.projects.count).to eq 2 - expect(last_project.projects[0].title).to eq sub_project1.title - expect(last_project.projects[1].title).to eq sub_project3.title + expect(last_project.projects.map(&:title)).to contain_exactly( + sub_project1.title, sub_project3.title + ) end it "allows to select/unselect all sub-projects at once" do diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index 5048201f..cd4fb12f 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -68,6 +68,46 @@ expect(page).to have_content "Story updated!" end + it "alerts me when I try to navigate away from the page without saving my edits", js: true do + visit project_path(id: project.id) + click_button "More actions" + click_link "Edit" + + click_link "Back" + assert_current_path project_path(id: project.id) + + click_button "More actions" + click_link "Edit" + + fill_in "story[title]", with: "As a user, I want to edit stories" + + dismiss_confirm("You have unsaved changes. Are you sure you want to leave?") do + find("#logo").click + end + + assert_current_path edit_project_story_path(project, story) + + accept_confirm("You have unsaved changes. Are you sure you want to leave?") do + click_link "Back" + end + + assert_current_path project_path(id: project.id) + end + + it "does not alert me when I revert my edits back to their original values", js: true do + visit edit_project_story_path(project, story) + original_title = find_field("story[title]").value + + # Make a change and then undo it, returning the form to its initial state. + fill_in "story[title]", with: "A temporary edit" + fill_in "story[title]", with: original_title + + # No unsaved-changes confirm should appear, so navigation happens directly. + click_link "Back" + + assert_current_path project_path(id: project.id) + end + it "allows me to delete a story" do visit project_path(id: project.id)