Skip to content

Commit c4a2ece

Browse files
torresgaJuanVqz
andauthored
DT-340: Warn the user if they are leaving a story edit page with unsaved changes (#334)
* Adds event listeners to listen for unsaved edits and alerting the user if there are unsaved edits * Adds spec to test for presence of alert when we try to navigate away from an unsaved edited story * Refactored the code to only add the event listeners on the form if the form existed. Also created a function to add and remove the beforeunload event listener * DT-340: Edit tests so that it tests all scenarios * Update app/assets/javascripts/stories.js Co-authored-by: Juan Vásquez <juan@ombulabs.com> * Update app/assets/javascripts/stories.js Co-authored-by: Juan Vásquez <juan@ombulabs.com> * Fix method name * Fix unsaved-changes detection to compare against initial form state - Track dirtiness by comparing the current form serialization to a snapshot taken on load, so reverting an edit (typing then deleting it) correctly clears the warning instead of leaving the form permanently 'dirty'. - Also listen for 'change' so status-select edits are detected, and guard the back/logo click handlers against missing elements on non-edit pages. - Add a feature spec covering the revert-to-original case. * Unify unsaved-changes wording and init on turbolinks:load - Route every in-app link that leaves the edit page through one confirm with a single shared message constant, instead of hard-coding only Back and the logo. The native beforeunload prompt (reload / tab close) still uses the browser's own text, since browsers ignore custom strings there. - Initialize on turbolinks:load instead of DOMContentLoaded, matching project.js, so the guard (and the markdown preview) also initialize on Turbolinks visits. Module-scoped state lets the delegated click handler be added by reference and de-duplicated across visits. * Fix flaky clone sub-projects spec ordering Assert the cloned sub-projects by set of titles (contain_exactly) instead of positional index. The projects association has no default order, so indexing into it was order-dependent and failed on the build-rails-next lane when the database returned the rows in a different order. --------- Co-authored-by: Juan Vásquez <juan@ombulabs.com>
1 parent 355315f commit c4a2ece

3 files changed

Lines changed: 140 additions & 4 deletions

File tree

app/assets/javascripts/stories.js

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
1-
document.addEventListener("DOMContentLoaded", () => {
1+
// Single source of truth for the unsaved-changes wording. It is shown for
2+
// every in-app link the user might leave through. The native beforeunload
3+
// prompt (reload / closing the tab) always uses the browser's own generic
4+
// text; browsers ignore any custom string there, so that one case aside, this
5+
// keeps the wording consistent everywhere we can control it.
6+
const UNSAVED_CHANGES_MESSAGE =
7+
"You have unsaved changes. Are you sure you want to leave?";
8+
9+
// Init on turbolinks:load (not DOMContentLoaded) so it also runs on Turbolinks
10+
// visits, matching project.js. reloadable state lives at module scope so the
11+
// delegated click handler can be added by reference and de-duplicated across
12+
// visits instead of stacking a new listener each time.
13+
let editForm = null;
14+
let editFormInitialState = null;
15+
let editFormDirty = false;
16+
17+
document.addEventListener("turbolinks:load", () => {
218
document.querySelectorAll("[data-has-preview]").forEach((element) => {
319
let debounceTimer;
420

@@ -25,8 +41,88 @@ document.addEventListener("DOMContentLoaded", () => {
2541
debounceTimer = window.setTimeout(updateMarkdown, 300);
2642
});
2743
});
44+
45+
initUnsavedChangesGuard();
2846
});
2947

48+
function initUnsavedChangesGuard() {
49+
editForm = document.querySelector(".edit_story");
50+
editFormDirty = false;
51+
// Start each visit from a clean slate; drop any beforeunload guard carried
52+
// over from a previous page.
53+
addBeforeUnloadEventListener(false);
54+
55+
if (!editForm) {
56+
return;
57+
}
58+
59+
// Snapshot the initial form state so we can compare against it. Tracking a
60+
// plain "changed at least once" flag reported the form as dirty even after
61+
// the user undid their edits (e.g. typed some text and then deleted it).
62+
editFormInitialState = serializeForm(editForm);
63+
64+
// "input" covers text fields; "change" covers selects like the status.
65+
editForm.addEventListener("input", refreshEditFormDirtyState);
66+
editForm.addEventListener("change", refreshEditFormDirtyState);
67+
editForm.addEventListener("submit", clearEditFormDirtyState);
68+
69+
// Same warning for every in-app link that leaves the page (Back, the logo,
70+
// Sign out, ...), not just a couple of buttons. Added by reference so it is
71+
// de-duplicated if turbolinks:load fires again.
72+
document.addEventListener("click", confirmLeaveIfUnsavedEdits);
73+
}
74+
75+
function refreshEditFormDirtyState() {
76+
editFormDirty = serializeForm(editForm) !== editFormInitialState;
77+
addBeforeUnloadEventListener(editFormDirty);
78+
}
79+
80+
function clearEditFormDirtyState() {
81+
editFormDirty = false;
82+
addBeforeUnloadEventListener(false);
83+
}
84+
85+
function confirmLeaveIfUnsavedEdits(event) {
86+
if (!editFormDirty) {
87+
return;
88+
}
89+
90+
const link = event.target.closest("a[href]");
91+
if (!link) {
92+
return;
93+
}
94+
95+
// Links that do not actually leave the page (new tab, in-page anchors,
96+
// javascript: handlers) should not trigger the warning.
97+
const href = link.getAttribute("href") || "";
98+
if (link.target === "_blank" || href.startsWith("#") || href.startsWith("javascript:")) {
99+
return;
100+
}
101+
102+
if (window.confirm(UNSAVED_CHANGES_MESSAGE)) {
103+
clearEditFormDirtyState();
104+
} else {
105+
event.preventDefault();
106+
}
107+
}
108+
109+
function serializeForm(form) {
110+
return new URLSearchParams(new FormData(form)).toString();
111+
}
112+
113+
function addBeforeUnloadEventListener(isDirty) {
114+
if (isDirty) {
115+
window.addEventListener("beforeunload", warnUserIfUnsavedEdits);
116+
} else {
117+
window.removeEventListener("beforeunload", warnUserIfUnsavedEdits);
118+
}
119+
}
120+
121+
function warnUserIfUnsavedEdits(event) {
122+
event.preventDefault();
123+
event.returnValue = UNSAVED_CHANGES_MESSAGE;
124+
}
125+
30126
function updateStatusButton(color, status) {
31127
const button = document.querySelector(".story-title .dropdown-wrapper > button");
32128
button.className = `button ${color}`;

spec/features/projects_manage_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,9 @@
266266

267267
last_project = Project.parents.last
268268
expect(last_project.id).not_to eq(project.id)
269-
expect(last_project.projects.count).to eq 2
270-
expect(last_project.projects[0].title).to eq sub_project1.title
271-
expect(last_project.projects[1].title).to eq sub_project3.title
269+
expect(last_project.projects.map(&:title)).to contain_exactly(
270+
sub_project1.title, sub_project3.title
271+
)
272272
end
273273

274274
it "allows to select/unselect all sub-projects at once" do

spec/features/stories_manage_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,46 @@
6868
expect(page).to have_content "Story updated!"
6969
end
7070

71+
it "alerts me when I try to navigate away from the page without saving my edits", js: true do
72+
visit project_path(id: project.id)
73+
click_button "More actions"
74+
click_link "Edit"
75+
76+
click_link "Back"
77+
assert_current_path project_path(id: project.id)
78+
79+
click_button "More actions"
80+
click_link "Edit"
81+
82+
fill_in "story[title]", with: "As a user, I want to edit stories"
83+
84+
dismiss_confirm("You have unsaved changes. Are you sure you want to leave?") do
85+
find("#logo").click
86+
end
87+
88+
assert_current_path edit_project_story_path(project, story)
89+
90+
accept_confirm("You have unsaved changes. Are you sure you want to leave?") do
91+
click_link "Back"
92+
end
93+
94+
assert_current_path project_path(id: project.id)
95+
end
96+
97+
it "does not alert me when I revert my edits back to their original values", js: true do
98+
visit edit_project_story_path(project, story)
99+
original_title = find_field("story[title]").value
100+
101+
# Make a change and then undo it, returning the form to its initial state.
102+
fill_in "story[title]", with: "A temporary edit"
103+
fill_in "story[title]", with: original_title
104+
105+
# No unsaved-changes confirm should appear, so navigation happens directly.
106+
click_link "Back"
107+
108+
assert_current_path project_path(id: project.id)
109+
end
110+
71111
it "allows me to delete a story" do
72112
visit project_path(id: project.id)
73113

0 commit comments

Comments
 (0)