Skip to content
Merged
98 changes: 97 additions & 1 deletion app/assets/javascripts/stories.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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}`;
Expand Down
6 changes: 3 additions & 3 deletions spec/features/projects_manage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions spec/features/stories_manage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading