diff --git a/app/assets/javascripts/stories.js b/app/assets/javascripts/stories.js index 5e7da51b..1afc0e3b 100644 --- a/app/assets/javascripts/stories.js +++ b/app/assets/javascripts/stories.js @@ -25,8 +25,71 @@ document.addEventListener("DOMContentLoaded", () => { debounceTimer = window.setTimeout(updateMarkdown, 300); }); }); + + const form = document.querySelector(".edit_story"); + const backButton = document.getElementById("back"); + const logo = document.getElementById("logo"); + let isDirty = false; + + if (form) { + // 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). + const initialState = serializeForm(form); + + const refreshDirtyState = function () { + isDirty = serializeForm(form) !== initialState; + addBeforeUnloadEventListener(isDirty); + }; + + // "input" covers text fields; "change" covers selects like the status. + form.addEventListener("input", refreshDirtyState); + form.addEventListener("change", refreshDirtyState); + + // Reset isDirty on form submission + form.addEventListener("submit", function () { + isDirty = false; + addBeforeUnloadEventListener(isDirty); + }); + } + + // Attach a click event to the custom back button + [backButton, logo].forEach(element => { + if (!element) return; + + element.addEventListener("click", function (event) { + if (isDirty) { + const confirmLeave = confirm("You have unsaved changes. Are you sure you want to go back?"); + if (confirmLeave) { + // Optionally, reset isDirty if leaving + isDirty = false; + addBeforeUnloadEventListener(isDirty) + } else { + // Prevent navigation if the user chooses not to leave + 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 = ''; +} + function updateStatusButton(color, status) { const button = document.querySelector(".story-title .dropdown-wrapper > button"); button.className = `button ${color}`; diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index 5048201f..2e711a61 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 go back?") 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 go back?") 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)