Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Robust changed content detection #2476

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integreat_cms/release_notes/current/unreleased/2477.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
en: Add unsaved changes indicator to title bar
de: Die Titelleiste weist jetzt auf ungespeicherte Änderungen hin
81 changes: 53 additions & 28 deletions integreat_cms/static/src/js/forms/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,57 @@ const formatDate = (date: Date) => {

export const autosaveEditor = async () => {
const form = document.getElementById("content_form") as HTMLFormElement;
tinymce.triggerSave();
const formData = new FormData(form);
// Override status to "auto save"
formData.append("status", "AUTO_SAVE");
// Override minor edit field to keep translation status
formData.set("minor_edit", "on");
// Show auto save remark
const autoSaveNote = document.getElementById("auto-save");
autoSaveNote.classList.remove("hidden");
const autoSaveTime = document.getElementById("auto-save-time");
autoSaveTime.innerText = formatDate(new Date());
form.addEventListener("input", () => {
autoSaveNote.classList.add("hidden");
});
const data = await fetch(form.action, {
method: "POST",
headers: {
"X-CSRFToken": getCsrfToken(),
},
body: formData,
});
// Set the form action to the url of the server response to make sure new pages aren't created multiple times
form.action = data.url;

// mark the content as saved
document.querySelectorAll("[data-unsaved-warning]").forEach((element) => {
element.dispatchEvent(new Event("autosave"));
});
// Content to be saved
// Also gets content out of the editor into the text field that was
// converted to the full editor, but returns it as well.
// Option format raw for UTF8 and no HTML encoding
const savingContent = tinymce.activeEditor.save({ source_view: true });

console.debug(
savingContent === tinymce.activeEditor.startContent ? "autosave not necessary" : "performing autosave…"
);

// Only save if content has changed
if (savingContent !== tinymce.activeEditor.startContent) {
document.querySelectorAll("[data-unsaved-warning]").forEach((element) => {
element.dispatchEvent(new Event("attemptingAutosave"));
});

// Prepare the data to send
const formData = new FormData(form);
// Override status to "auto save"
formData.append("status", "AUTO_SAVE");
// Override minor edit field to keep translation status
formData.set("minor_edit", "on");
// Show auto save remark
const autoSaveNote = document.getElementById("auto-save");
autoSaveNote.classList.remove("hidden");
const autoSaveTime = document.getElementById("auto-save-time");
autoSaveTime.innerText = formatDate(new Date());
form.addEventListener("input", () => {
autoSaveNote.classList.add("hidden");
});
const data = await fetch(form.action, {
method: "POST",
headers: {
"X-CSRFToken": getCsrfToken(),
},
body: formData,
});
if (data.ok) {
// Set the form action to the url of the server response to make sure new pages aren't created multiple times
form.action = data.url;

// Set the now successfully saved content as the start content,
// so that we can always compare whether any changes were made since the last save.
tinymce.activeEditor.startContent = savingContent;

// mark the content as saved
document.querySelectorAll("[data-unsaved-warning]").forEach((element) => {
element.dispatchEvent(new Event("autosave"));
});
} else {
console.warn("Autosave failed!", data);
}
}
};
10 changes: 9 additions & 1 deletion integreat_cms/static/src/js/forms/tinymce-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ const toggleNoTranslate = (editor: Editor) => {
}
};

export const getContent = (): string => tinymce.activeEditor.getContent();
export const getContent = (args: object = {}): string => {
const defaultArgs = { source_view: true };
return tinymce.activeEditor.getContent({ ...{ defaultArgs }, ...args });
};

export const editors = tinymce.editors;

export const isActuallyDirty = (editor: Editor) => editor.startContent !== editor.getContent({ source_view: true });

/**
* This file initializes the tinymce editor.
Expand Down Expand Up @@ -89,6 +96,7 @@ window.addEventListener("load", () => {
},
link_title: false,
autosave_interval: "120s",
autosave_ask_before_unload: false, // We implement that ourselves
forced_root_block: true,
plugins: "code fullscreen autosave preview media image lists directionality wordcount hr charmap paste",
external_plugins: {
Expand Down
113 changes: 91 additions & 22 deletions integreat_cms/static/src/js/unsaved-warning.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,109 @@
/**
* This file contains a function to warn the user when they leave a content without saving
*/
import { editors, isActuallyDirty } from "./forms/tinymce-init";

let dirty = false;
const takeFormSnapshot = (form: HTMLFormElement) => new FormData(form);

window.addEventListener("beforeunload", (event) => {
// trigger only when something is edited and no submit/save button clicked
if (dirty) {
event.preventDefault();
/* eslint-disable-next-line no-param-reassign */
event.returnValue = "This content is not saved. Would you leave the page?";
const equalSnapshots = (a: FormData, b: FormData) => {
// We could inplement a proper comparison of nested arrays
// …ooor we could just take the hit and stringify everything
// to JSON, saving us maintenance on all these code lines
if (a == null || b == null) {
return false;
}
});
const withoutExcluded = (iterator: IterableIterator<[string, FormDataEntryValue]>) =>
Array.from(iterator).filter((item) => {
const target = document.querySelector(`[name="${item[0]}"]`);
return !target.hasAttribute("data-unsaved-warning-exclude");
});
const aJSON = JSON.stringify(withoutExcluded(a.entries()));
const bJSON = JSON.stringify(withoutExcluded(b.entries()));
return aJSON === bJSON;
};

const originalTitle = document.title;

let snapshotCandidate: FormData | null = null;
let savedSnapshot: FormData | null = null;

const isDirty = (form: HTMLFormElement) => {
const now = takeFormSnapshot(form);
if (!Array.from(now.entries()).length) {
console.warn("form snapshot has no entries:", now.entries(), form);
}
if (!equalSnapshots(now, savedSnapshot)) {
return true;
}
// Are there any unsaved changes in the editors?
for (const editor of editors) {
if (isActuallyDirty(editor)) {
return true;
}
}
return false;
};

// Only temporarily add a beforeunload event
// https://developer.chrome.com/articles/page-lifecycle-api/#the-beforeunload-event
const beforeunload = (event: BeforeUnloadEvent) => {
console.debug("[beforeunload]");
event.preventDefault();
/* eslint-disable-next-line no-param-reassign */
event.returnValue = "This content is not saved. Would you leave the page?";
return event.returnValue;
};

const updateState = (form: HTMLFormElement) => {
// Add/remove beforeunload listener and add an unsaved indicator to the title
if (isDirty(form)) {
window.addEventListener("beforeunload", beforeunload);
document.title = `• ${originalTitle}`;
} else {
window.removeEventListener("beforeunload", beforeunload);
document.title = originalTitle;
}
};

window.addEventListener("load", () => {
const form = document.querySelector("[data-unsaved-warning]");
// checks whether the user typed something in the content
form?.addEventListener("input", (event) => {
// does not set the dirty flag, if the event target is explicitly excluded
const target = event.target as HTMLElement;
if (target.hasAttribute("data-unsaved-warning-exclude")) {
return;
const form = document.querySelector("[data-unsaved-warning]") as HTMLFormElement;

// Remember the state on initialization as saved
savedSnapshot = takeFormSnapshot(form);

editors.forEach((editor) => {
const target = editor.targetElm as HTMLInputElement;
if (target.form === form) {
editor.on("input", () => {
updateState(form);
});
}
if (!dirty) {
console.debug("editing detected, enabled beforeunload warning");
});

// checks whether the user typed something in the content
form?.addEventListener("input", () => {
updateState(form);
});

form?.addEventListener("formdata", (event) => {
// ensure form has latest tinymce state
for (const editor of editors) {
const name = (editor.targetElm as HTMLFormElement).name;
const content = editor.getContent({ source_view: true });
event.formData.set(name, content);
}
dirty = true;
});
// checks whether the user has saved or submitted the content
form?.addEventListener("submit", () => {
dirty = false;
console.debug("form submitted, disabled beforeunload warning");
window.removeEventListener("beforeunload", beforeunload);
});
// take snapshot when attempting autosave
form?.addEventListener("attemptingAutosave", () => {
snapshotCandidate = takeFormSnapshot(form);
});
// removes the warning on autosave
form?.addEventListener("autosave", () => {
dirty = false;
console.debug("Autosave, disabled beforeunload warning");
savedSnapshot = snapshotCandidate;
updateState(form);
});
});