Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,117 @@ window.App = {
return this.related[entity] || [];
},
};

/**
* A transient message about something the user just did ("Saved"), over Harmonia's own notification
* overlay. Deliberately NOT the notifications store's announce(): that also writes an entry into the
* bell, which is right for the outcome of an action the user launched and wrong for an ordinary save
* - the bell would fill up with them. Missing overlay degrades to the console.
*/
App.services.toast = function (message, variant) {
try {
const toasts = window.Alpine && Alpine.store('_h_notifications');
if (toasts && typeof toasts.push === 'function') {
toasts.push(undefined, 'toast', 'top-right', 4000, { message: message, variant: variant || 'information' });
return;
}
} catch (e) { /* fall through to the console */ }
console.log('[toast] ' + message);
};

/**
* Unsaved-changes guard (issue #7359).
*
* A generated form knows when its buffer differs from what the server holds; nothing else does. So
* the open form REGISTERS itself here while it is dirty, and every way out of it - the page's own
* Back / Cancel, a sidebar entry, the browser's Back button, a reload, closing the tab - asks this
* one guard first. Without it an edited header was dropped silently by any of them.
*
* In-app navigation is vetoed through Pinecone's own global handler: a handler that THROWS aborts
* `navigate` before the route is rendered and before the history entry is pushed (router 7.5.2), so
* the page the user is standing on keeps its address while the dialog is up. The browser's Back
* button has already moved the URL by then - `restoreHash` puts it back - and `bypass` is what lets
* the guard's own "Save and leave" / "Discard" perform the navigation it just refused.
*
* The page supplies both halves: `isDirty()` (is there anything to protect) and `confirmLeave(proceed,
* intent)` (raise the dialog, call `proceed` when the user says so). See basePage.
*/
App.leaveGuard = {
page: null,
bypass: false,
installed: false,
// The full hash the guarded page lives at (query string included - Pinecone's context.path drops it).
homeHash: '',

register(page) {
this.page = page;
this.homeHash = window.location.hash || '';
},

release(page) {
if (this.page === page) {
this.page = null;
this.homeHash = '';
}
},

isDirty() {
try {
return !!(this.page && typeof this.page.isDirty === 'function' && this.page.isDirty());
} catch (e) {
// A guard that throws must never be able to trap the user on a page.
console.error('[leaveGuard] the registered page could not report its state', e);
return false;
}
},

// Put the URL back to the guarded page's own address (the browser Back button moved it before we
// were asked). Same-document hash change, so nothing reloads.
restoreHash() {
try {
if (!this.homeHash || window.location.hash === this.homeHash) return;
window.history.pushState({ path: this.homeHash }, '', this.homeHash);
} catch (e) {
console.error('[leaveGuard] could not restore the address of the open form', e);
}
},

// Perform a navigation the guard itself decided to allow.
go(path) {
this.bypass = true;
this.page = null;
this.homeHash = '';
Promise.resolve(window.PineconeRouter.navigate(path)).catch((e) => {
console.error('[leaveGuard] navigation failed', e);
}).then(() => { this.bypass = false; });
},

install() {
if (this.installed || !window.PineconeRouter || typeof window.PineconeRouter.settings !== 'function') return;
this.installed = true;
const guard = this;
window.PineconeRouter.settings({
globalHandlers: [(context) => {
if (guard.bypass || !guard.isDirty()) return;
// Where the user wanted to go. The browser's Back button has already put that address in the
// bar, query string and all - Pinecone's own context.path drops the query - so prefer it when
// it is the same route; a programmatic navigate has not touched the bar yet.
const shown = (window.location.hash || '').replace(/^#/, '');
const target = shown && shown.split('?')[0] === context.path ? shown : context.path;
guard.restoreHash();
guard.page.confirmLeave(() => guard.go(target), 'leave');
// Pinecone aborts the navigation when a global handler throws - this is the veto.
throw new Error('[leaveGuard] navigation stopped: the open form has unsaved changes');
}],
});
window.addEventListener('beforeunload', (event) => {
if (!guard.isDirty()) return undefined;
// Reload / close tab: only the browser's own generic prompt is available here.
event.preventDefault();
event.returnValue = '';
return '';
});
},
};

document.addEventListener('alpine:init', () => App.leaveGuard.install());
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,133 @@ function basePage() {
destroy() {
(this._actionDoneHandlers || []).forEach((listener) => window.removeEventListener('harmonia:action-done', listener));
this._actionDoneHandlers = [];
// The two files are cached by the browser independently, so a page may be running this one
// against a previous app.js - which must not turn every destroy into an exception.
if (App.leaveGuard) App.leaveGuard.release(this);
},

// ===== Unsaved changes (issue #7359) =========================================================
// A form page takes a snapshot of what it loaded (markPristine) and is DIRTY while its buffer
// differs from it. Every exit - the page's own Back / Cancel, a sidebar entry, the browser's
// Back button, a reload - then goes through guardExit / App.leaveGuard instead of dropping the
// edit silently. A page that never calls markPristine (a list, a report) is never dirty and
// everything here stays inert.
//
// The comparison is over the SAVE PAYLOAD, not `form`: toPayload already normalizes dates,
// dropdown ids to strings and a multiselect to its csv, so a value that only LOOKS different
// (3 vs "3") does not read as an edit.

// The payload as it was last known to be saved, serialized. null = nothing to protect.
pristine: null,
// The guard dialog's own state. `leaveIntent` is 'leave' (an exit) or 'continue' (the user is
// staying on the page but starting something that would save behind the dirty header).
leaveOpen: false,
leaveIntent: 'leave',
leaveBusy: false,
leaveProceed: null,

dirtySnapshot() {
try {
return JSON.stringify(typeof this.toPayload === 'function' ? this.toPayload() : this.form);
} catch (e) {
console.error('[leaveGuard] could not snapshot the form', e);
return null;
}
},

// Declare the current buffer saved. Called after a load and after every successful save; the page
// registers with the shared guard from here, so the guard is armed exactly while a form is open.
markPristine() {
this.pristine = this.dirtySnapshot();
if (App.leaveGuard) App.leaveGuard.register(this);
},

// Stop guarding (the page is about to navigate itself after a successful write, or the user
// discarded). Not a getter - see the note at the top of baseFormPage about spread and getters.
clearPristine() {
this.pristine = null;
if (App.leaveGuard) App.leaveGuard.release(this);
},

isDirty() {
if (this.pristine === null) return false;
// A read-only surface cannot be edited, so it can never be dirty: preview, and a document the
// immutability pre-check reported closed (its inputs are disabled).
if (this.isPreview === true || this.mutable === false) return false;
const snapshot = this.dirtySnapshot();
// A snapshot that could not be taken says nothing about the buffer - reporting it as an edit
// would put the dialog in front of every exit for the rest of the session.
return snapshot !== null && snapshot !== this.pristine;
},

/**
* Run `proceed` unless the form has unsaved changes - then ask first. `intent` is 'leave' (the
* default) or 'continue' for an action that stays on the page (opening the line-item dialog,
* whose save would otherwise commit a line under a header the server has never seen).
*/
guardExit(proceed, intent) {
if (!this.isDirty()) {
proceed();
return;
}
this.confirmLeave(proceed, intent);
},

/** Raise the guard dialog. Also the entry point the shared router guard calls. */
confirmLeave(proceed, intent) {
this.leaveProceed = proceed;
this.leaveIntent = intent || 'leave';
this.leaveOpen = true;
},

leaveKeepEditing() {
this.leaveOpen = false;
this.leaveProceed = null;
},

/**
* Discard: leave without saving. On an exit there is nothing to restore - the page is going away
* - so the snapshot is simply dropped; when the user is STAYING ('continue') the buffer is put
* back to what the server holds, which is what makes the line save honest again.
*/
async leaveDiscard() {
const proceed = this.leaveProceed;
this.leaveOpen = false;
this.leaveProceed = null;
if (this.leaveIntent === 'continue' && typeof this.reloadForm === 'function') {
this.leaveBusy = true;
try {
await this.reloadForm();
} catch (e) {
console.error('[leaveGuard] could not restore the form', e);
} finally {
this.leaveBusy = false;
}
} else {
this.clearPristine();
}
if (proceed) proceed();
},

/**
* Save, then do what the user was trying to do. A refused save (a validation error, a 400/409 the
* server raised) keeps the user on the page with the usual summary - the dialog closes, because
* the message it hides is the answer to the question it asked.
*/
async leaveSave() {
const proceed = this.leaveProceed;
this.leaveBusy = true;
try {
await this.save();
} catch (e) {
console.error('[leaveGuard] save failed', e);
} finally {
this.leaveBusy = false;
}
this.leaveOpen = false;
this.leaveProceed = null;
if (this.isDirty()) return; // refused: the page reports why, and stays
if (proceed) proceed();
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ document.addEventListener('alpine:init', () => {
await this.loadItems();
if (this.loadChildren) this.loadChildren();
this.state = 'default';
// What the server holds is what every exit compares the buffer against (issue #7359).
this.markPristine();
} catch (e) {
this.error = App.services.apiErrors.refusalMessageFor(e, 'Could not load the document.');
this.state = 'error';
Expand All @@ -159,9 +161,17 @@ document.addEventListener('alpine:init', () => {
if (v !== null) this.form[key] = v;
}
this.state = 'default';
// Create mode: the blank form plus the query-param prefills is where the user started;
// anything typed after this is an unsaved change (issue #7359).
this.markPristine();
}
},

// The guard dialog's Discard when the user is STAYING on the page: re-read the document.
async reloadForm() {
await this.init();
},

queryParam(name) {
const search = (window.location.hash.split('?')[1] || '');
const params = new URLSearchParams(search);
Expand Down Expand Up @@ -235,6 +245,9 @@ document.addEventListener('alpine:init', () => {
if (this.mode === 'edit') {
await App.services.api.put(this.apiPath + '/' + this.id, this.toPayload());
await this.loadItems();
// The buffer is now what the server holds, and the user is told so (issue #7359).
this.markPristine();
App.services.toast(T('$projectName:${tprefix}.messages.saved', 'Saved'), 'positive');
} else {
const created = await App.services.api.post(this.apiPath, this.toPayload());
const pk = created && (created.Id != null ? created.Id : created.id);
Expand All @@ -247,6 +260,8 @@ document.addEventListener('alpine:init', () => {
window.history.replaceState(window.history.state, '', url);
}
await this.loadItems();
this.markPristine();
App.services.toast(T('$projectName:${tprefix}.messages.saved', 'Saved'), 'positive');
}
}
} catch (e) {
Expand Down Expand Up @@ -370,6 +385,9 @@ document.addEventListener('alpine:init', () => {
// `preset` seeds a CREATE draft with values the caller already knows (the day clicked on the items
// calendar).
async openItem(row, preset) {
// A line is saved on its own, against the header as the server holds it - so starting one with
// an unsaved header leaves the user believing the whole document was saved (issue #7359).
if (this.isDirty()) { this.confirmLeave(() => this.openItem(row, preset), 'continue'); return; }
const d = this.def();
await this.ensureItemOptions();
this.itemMode = row ? 'edit' : 'create';
Expand Down Expand Up @@ -607,6 +625,7 @@ document.addEventListener('alpine:init', () => {
try {
await App.services.api.delete(this.apiPath + '/' + this.id);
this.deleteOpen = false;
this.clearPristine();
this.goBack();
} catch (e) {
this.error = App.services.apiErrors.refusalMessageFor(e, 'Could not delete.');
Expand All @@ -616,7 +635,9 @@ document.addEventListener('alpine:init', () => {
}
},

goBack() { this.navigate('/my/${name}'); },
// A document with unsaved header edits asks before it is abandoned (issue #7359); a clean one
// leaves silently, as before.
goBack() { this.guardExit(() => this.navigate('/my/${name}')); },
#set($docChildren = [])
#if($myChildren)
#foreach($child in $myChildren)
Expand Down Expand Up @@ -726,11 +747,11 @@ document.addEventListener('alpine:init', () => {

addChild(child) {
if (child.readOnly) return;
this.navigate('/my/' + child.name + '/create?' + encodeURIComponent(child.fkProperty) + '='
+ encodeURIComponent(this.id) + '&returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit'));
this.guardExit(() => this.navigate('/my/' + child.name + '/create?' + encodeURIComponent(child.fkProperty) + '='
+ encodeURIComponent(this.id) + '&returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit')));
},
editChild(child, id) {
this.navigate('/my/' + child.name + '/' + id + '/edit?returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit'));
this.guardExit(() => this.navigate('/my/' + child.name + '/' + id + '/edit?returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit')));
},
onChildEventClick(child, e) {
const id = e && e.detail && e.detail.event ? e.detail.event.id : null;
Expand All @@ -747,7 +768,7 @@ document.addEventListener('alpine:init', () => {
if (e.detail.time) val += 'T' + e.detail.time;
q += '&' + encodeURIComponent(child.calendar.start) + '=' + encodeURIComponent(val);
}
this.navigate('/my/' + child.name + '/create' + q);
this.guardExit(() => this.navigate('/my/' + child.name + '/create' + q));
},
#end
}));
Expand Down
Loading
Loading