diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js
index 628804c028d..4ae3b094dcb 100644
--- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js
+++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js
@@ -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());
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/basePage.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/basePage.js
index a7abdb6eee6..c8fefc17f88 100644
--- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/basePage.js
+++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/basePage.js
@@ -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();
},
/**
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template
index d84c75ba653..cca3a69534c 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template
@@ -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';
@@ -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);
@@ -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);
@@ -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) {
@@ -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';
@@ -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.');
@@ -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)
@@ -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;
@@ -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
}));
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template
index acfc77de076..0f50a8a7e64 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template
@@ -30,6 +30,8 @@
#end
+
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-page.js.template
index fc223b9eda4..6175d25aa87 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-page.js.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-page.js.template
@@ -93,6 +93,8 @@ document.addEventListener('alpine:init', () => {
#end
#end
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 record.');
this.state = 'error';
@@ -109,9 +111,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 record.
+ async reloadForm() {
+ await this.init();
+ },
+
queryParam(name) {
const search = (window.location.hash.split('?')[1] || '');
const params = new URLSearchParams(search);
@@ -197,6 +207,9 @@ document.addEventListener('alpine:init', () => {
} else {
await App.services.api.post(this.apiPath, this.toPayload());
}
+ // The write landed, so there is nothing left to protect - clear the guard before the page
+ // navigates itself, or it would veto its own route change (issue #7359).
+ this.clearPristine();
this.goBack();
} catch (e) {
this.applyApiError(e, 'Could not save.');
@@ -210,6 +223,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.');
@@ -219,7 +233,11 @@ document.addEventListener('alpine:init', () => {
}
},
- goBack() {
+ // A form with unsaved edits asks before it is abandoned (issue #7359); a clean one leaves
+ // silently, as before. Back and Cancel both come through here.
+ goBack() { this.guardExit(() => this.leavePage()); },
+
+ leavePage() {
const returnTo = this.queryParam('returnTo');
if (returnTo) { this.navigate(returnTo); return; }
#if($personalParent)
@@ -330,11 +348,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;
@@ -350,7 +368,7 @@ document.addEventListener('alpine:init', () => {
q += '&' + encodeURIComponent(child.calendar.start) + '='
+ encodeURIComponent(d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()));
}
- this.navigate('/my/' + child.name + '/create' + q);
+ this.guardExit(() => this.navigate('/my/' + child.name + '/create' + q));
},
#end
}));
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-view.html.template
index 5e0d6574a03..8e45b47e96f 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-view.html.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-view.html.template
@@ -34,6 +34,8 @@
#end
+
+
@@ -224,9 +226,10 @@
+
-
+
@@ -253,4 +256,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-page.js.template
index b24c15546bb..cb80256292d 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-page.js.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-page.js.template
@@ -137,6 +137,8 @@ document.addEventListener('alpine:init', () => {
this.itemsEnabled = true;
await this.loadItems();
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';
@@ -149,9 +151,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);
@@ -225,6 +235,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);
@@ -237,6 +250,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) {
@@ -368,6 +383,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';
@@ -493,6 +511,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.');
@@ -502,6 +521,8 @@ document.addEventListener('alpine:init', () => {
}
},
- goBack() { this.navigate('/partner/${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('/partner/${name}')); },
}));
});
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-view.html.template
index 586786d29af..741d4b1f418 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-view.html.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-document-view.html.template
@@ -30,6 +30,8 @@
#end
+
+
@@ -218,9 +220,10 @@
+
-
+
@@ -310,4 +313,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-page.js.template
index 6e73bc98de6..2c9e3915da3 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-page.js.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-page.js.template
@@ -93,6 +93,8 @@ document.addEventListener('alpine:init', () => {
#end
#end
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 record.');
this.state = 'error';
@@ -109,9 +111,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 record.
+ async reloadForm() {
+ await this.init();
+ },
+
queryParam(name) {
const search = (window.location.hash.split('?')[1] || '');
const params = new URLSearchParams(search);
@@ -197,6 +207,9 @@ document.addEventListener('alpine:init', () => {
} else {
await App.services.api.post(this.apiPath, this.toPayload());
}
+ // The write landed, so there is nothing left to protect - clear the guard before the page
+ // navigates itself, or it would veto its own route change (issue #7359).
+ this.clearPristine();
this.goBack();
} catch (e) {
this.applyApiError(e, 'Could not save.');
@@ -210,6 +223,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.');
@@ -219,7 +233,11 @@ document.addEventListener('alpine:init', () => {
}
},
- goBack() {
+ // A form with unsaved edits asks before it is abandoned (issue #7359); a clean one leaves
+ // silently, as before. Back and Cancel both come through here.
+ goBack() { this.guardExit(() => this.leavePage()); },
+
+ leavePage() {
const returnTo = this.queryParam('returnTo');
if (returnTo) { this.navigate(returnTo); return; }
#if($partnerParent)
@@ -312,11 +330,11 @@ document.addEventListener('alpine:init', () => {
},
addChild(child) {
- this.navigate('/partner/' + child.name + '/create?' + encodeURIComponent(child.fkProperty) + '='
- + encodeURIComponent(this.id) + '&returnTo=' + encodeURIComponent('/partner/${name}/' + this.id + '/edit'));
+ this.guardExit(() => this.navigate('/partner/' + child.name + '/create?' + encodeURIComponent(child.fkProperty) + '='
+ + encodeURIComponent(this.id) + '&returnTo=' + encodeURIComponent('/partner/${name}/' + this.id + '/edit')));
},
editChild(child, id) {
- this.navigate('/partner/' + child.name + '/' + id + '/edit?returnTo=' + encodeURIComponent('/partner/${name}/' + this.id + '/edit'));
+ this.guardExit(() => this.navigate('/partner/' + child.name + '/' + id + '/edit?returnTo=' + encodeURIComponent('/partner/${name}/' + this.id + '/edit')));
},
onChildEventClick(child, e) {
const id = e && e.detail && e.detail.event ? e.detail.event.id : null;
@@ -331,7 +349,7 @@ document.addEventListener('alpine:init', () => {
q += '&' + encodeURIComponent(child.calendar.start) + '='
+ encodeURIComponent(d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()));
}
- this.navigate('/partner/' + child.name + '/create' + q);
+ this.guardExit(() => this.navigate('/partner/' + child.name + '/create' + q));
},
#end
}));
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-view.html.template
index d46285361e9..05761ea8f0b 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-view.html.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/partner/partner-form-view.html.template
@@ -34,6 +34,8 @@
#end
+
+
@@ -215,9 +217,10 @@
+
-
+
@@ -243,4 +246,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template
index 5a1d46f3d6b..d8faad1a9b1 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template
@@ -421,6 +421,10 @@ document.addEventListener('alpine:init', () => {
});
#end
#end
+ // Create mode: the blank form plus whatever the URL and the depends-on prefills put in it is
+ // the state the user started from; anything typed after this is an unsaved change (#7359).
+ if (!this.isEdit && !this.isPreview) this.markPristine();
+
// A custom action (a transition, a create-from, an action page) changes this document behind
// the page's back: its status flips, its totals move, its panels gain rows. Re-read the header
// so the status pill, the stepper and the transition buttons - which are filtered by the
@@ -477,9 +481,18 @@ document.addEventListener('alpine:init', () => {
await this.ensureFilteredCurrent('${property.name}', '${property.widgetDropdownControllerUrl}', '${property.widgetDropDownKey}', '${property.widgetDropDownValue}');
#end
#end
+ // The header now holds exactly what the server holds: that is the state every exit is compared
+ // against (issue #7359). Re-taken after every item change, which re-reads the header too.
+ this.markPristine();
this.refreshIcons();
},
+ // Put the header back to what the server holds - the guard dialog's Discard when the user is
+ // STAYING on the document (the line dialog it was about to open saves against this header).
+ async reloadForm() {
+ await this.loadHeader();
+ },
+
async loadOptions() {
#foreach($property in $properties)
#if(($property.widgetType == "DROPDOWN" || $property.widgetType == "DOCUMENT_STATUS" || $property.widgetType == "MULTISELECT") && $property.aggregate != "true")
@@ -595,7 +608,14 @@ document.addEventListener('alpine:init', () => {
const payload = this.toPayload();
if (this.isEdit) {
await App.services.api.put(this.apiPath + '/' + encodeURIComponent(this.id), payload);
- this.backToList();
+ // Save STAYS on the document (issue #7359): saving and leaving were the same click, so a
+ // user who saved a header mid-session was thrown back to the list. Re-read it instead -
+ // the server's calculated fields and totals come back with it - and say it was saved. The
+ // way out is Back to list, which is now guarded. Create mode already worked this way.
+ await this.loadHeader();
+ await this.loadItems();
+ this.state = 'ready';
+ App.services.toast(T('$projectName:${tprefix}.messages.saved', 'Saved'), 'positive');
return;
} else {
// Create the header, then stay on the document (now in edit mode) so items can be added.
@@ -604,6 +624,9 @@ document.addEventListener('alpine:init', () => {
if (newId != null) {
this.id = newId;
this.mode = 'edit';
+ // The write landed, so there is nothing left to protect: clear the guard before the page
+ // navigates itself, or it would veto its own route change.
+ this.clearPristine();
window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(newId) + '/edit');
await this.loadHeader();
await this.loadItems();
@@ -711,6 +734,7 @@ document.addEventListener('alpine:init', () => {
await App.services.api.post(this.itemsDef.apiPath, body);
}
}
+ this.clearPristine();
window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(newId) + '/edit');
} catch (e) {
this.applyApiError(e, { fallbackMessage: 'Could not duplicate the document.' });
@@ -1110,6 +1134,10 @@ document.addEventListener('alpine:init', () => {
// right on first render.
openRowDialog(row, preset) {
if (!this.itemsEnabled || this.isPreview) return;
+ // A line is saved on its own, against the header AS THE SERVER HOLDS IT, and saving one
+ // re-reads the header - so starting a line with an unsaved header both hides the edit and
+ // leaves the user believing the whole document was saved (issue #7359). Ask first.
+ if (this.isDirty()) { this.confirmLeave(() => this.openRowDialog(row, preset), 'continue'); return; }
this.draftError = null;
this.draftFieldError = null;
this.draftMode = row ? 'edit' : 'create';
@@ -1246,6 +1274,9 @@ document.addEventListener('alpine:init', () => {
openFillDialog() {
const col = this.fillDateColumn();
if (!col) return;
+ // Same reason as openRowDialog - asked here so the answer reopens the FILL dialog, not the
+ // plain line dialog.
+ if (this.isDirty()) { this.confirmLeave(() => this.openFillDialog(), 'continue'); return; }
this.openRowDialog(null);
if (!this.rowDialogOpen) return; // openRowDialog refused (preview / items disabled)
this.draftMode = 'fill';
@@ -1303,7 +1334,13 @@ document.addEventListener('alpine:init', () => {
return String(v).slice(0, 10);
},
- askDeleteRow(row) { if (this.isPreview) return; this.deleteTarget = row; this.deleteOpen = true; },
+ // Deleting a line re-reads the header (the totals move), which would take an unsaved header edit
+ // with it - so the dirty header is settled first (issue #7359).
+ askDeleteRow(row) {
+ if (this.isPreview) return;
+ if (this.isDirty()) { this.confirmLeave(() => this.askDeleteRow(row), 'continue'); return; }
+ this.deleteTarget = row; this.deleteOpen = true;
+ },
async confirmDeleteRow() {
if (!this.deleteTarget) return;
@@ -1328,7 +1365,9 @@ document.addEventListener('alpine:init', () => {
return window.HarmoniaFormat.number(v, pattern);
},
- backToList() { window.PineconeRouter.navigate('/${name}'); },
+ // Every exit from the document goes through the guard: a dirty header is never dropped without
+ // being asked about (issue #7359). A clean one leaves silently, as before.
+ backToList() { this.guardExit(() => window.PineconeRouter.navigate('/${name}')); },
// Preview -> the editable document for the same record.
goEdit() { window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(this.id) + '/edit'); },
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template
index d9ca04249c0..7b6aa56337c 100644
--- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template
+++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template
@@ -50,9 +50,11 @@
button here the URL is knowledge the user has to type. Edit/preview only - a record that is
not saved yet has no id to filter by. -->
#foreach($scopedCalendar in $scopedCalendars)
-
+
#end
#end
+
+
@@ -549,7 +551,7 @@
#if($duplicable)
-