From c4ea9d2ebb1fdce8cd0de0b295376c7b375deefa Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 12 Sep 2026 19:38:54 +0300 Subject: [PATCH] ui: a generated form guards its unsaved changes, and Save stays on the document (#7359) A generated Harmonia form had no notion of unsaved changes. An edited header was dropped without a word by Back to list, the footer Cancel, a sidebar entry, the browser's Back button and a reload; nothing marked the form as dirty; saving a line item while the header was dirty committed the line and left the user believing the whole document was saved; and Save in edit mode navigated to the list, so saving and leaving were the same click. The mechanism is one, and it lives in the SHARED runtime: - basePage snapshots the SAVE PAYLOAD after every load and after every successful save (toPayload already normalizes dates, dropdown ids and a multiselect csv, so "3" vs 3 does not read as an edit) and is dirty while the buffer differs from it. Preview and a document the /mutable pre-check reported closed can never be dirty. - App.leaveGuard is what the open form registers with. In-app navigation is vetoed through Pinecone's own global handler - a handler that throws aborts navigate before the route renders and before the history entry is pushed (router 7.5.2) - so the page keeps its address while the dialog is up; the browser's Back button has already moved the URL, which restoreHash puts back; a reload or a closed tab falls to beforeunload. - One dialog per view with three answers: Save and leave (Save and continue when the user is staying), Discard, Keep editing. An "Unsaved changes" badge in the header bar, Save disabled on a clean edit form, and the footer Cancel relabelled to Discard while dirty. Wired into every generated form that owns an editable header: the document page (plus its line-item Add / Fill Month / line delete, each of which would otherwise save behind an unsaved header), the manage form, and the my / partner form and document pages. On the document surfaces Save now STAYS on the record - it re-reads the header and toasts "Saved" - and clears the guard before any navigation it performs itself. The admin inline editor is deliberately left out: it is a standalone page with no router and no shared runtime, and its Cancel is an explicit action inside its own panel. Verified: DependsOnHarmoniaIT (Chrome) drives the journey on a generated form - dirty badge, Back to list vetoed, the dialog, Keep editing, the edit still there; HarmoniaUnsavedChangesIT is the sweep that keeps a surface from being left behind; ModelGenerationIT (every template renders, twice) and IntentEmissionCoverageIT (a generated intent app with a document and a personal surface) both green; formatter:validate green with the cache wiped. Fixes #7359 Co-Authored-By: Claude Opus 5 --- .../application-core/shell/js/app.js | 114 ++++++++++++++++ .../shell/js/components/pages/basePage.js | 127 ++++++++++++++++++ .../ui/my/my-document-page.js.template | 31 ++++- .../ui/my/my-document-view.html.template | 31 ++++- .../ui/my/my-form-page.js.template | 28 +++- .../ui/my/my-form-view.html.template | 31 ++++- .../partner/partner-document-page.js.template | 23 +++- .../partner-document-view.html.template | 31 ++++- .../ui/partner/partner-form-page.js.template | 28 +++- .../partner/partner-form-view.html.template | 31 ++++- .../document/document-page.js.template | 45 ++++++- .../document/document-view.html.template | 36 ++++- .../perspective/manage/form-page.js.template | 36 ++++- .../manage/form-view.html.template | 36 ++++- .../ui/translations.json.template | 9 +- .../tests/api/HarmoniaUnsavedChangesIT.java | 100 ++++++++++++++ .../tests/DependsOnHarmoniaTestProject.java | 32 +++++ 17 files changed, 726 insertions(+), 43 deletions(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaUnsavedChangesIT.java 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
+ + @@ -356,9 +358,10 @@
+ - + @@ -458,4 +461,28 @@ + +
+
+
+

+ +
+
+

+
+
+ + + +
+
+
+ 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) - #end @@ -568,8 +570,10 @@ - - + + @@ -811,4 +815,28 @@ + +
+
+
+

+ +
+
+

+
+
+ + + +
+
+
+ diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-page.js.template index 182baaffaa2..b08b8349a9f 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-page.js.template @@ -267,6 +267,9 @@ document.addEventListener('alpine:init', () => { this.$watch('form', () => this.recalcCalculated()); this.recalcCalculated(); #end + // Create mode: the blank form plus the query-param prefills and the depends-on/calculated + // values is where the user started; anything typed after this is an unsaved change (#7359). + if (!this.isEdit && !this.isPreview) this.markPristine(); this.refreshIcons(); }, @@ -328,6 +331,8 @@ document.addEventListener('alpine:init', () => { await this.loadHistory(); #end this.state = 'ready'; + // What the server holds is what every exit compares the buffer against (issue #7359). + this.markPristine(); } catch (e) { this.applyLoadError(e, { fallbackMessage: 'Could not load the ${name} record.' }); } @@ -571,6 +576,7 @@ document.addEventListener('alpine:init', () => { // Opened as a detail-panel iframe dialog: report the save to the opener (it closes the // dialog and reloads the panel) instead of navigating this iframe to a list nobody sees. if (this.isDialog) { + this.clearPristine(); this.emitSaved(this.id); return; } @@ -580,6 +586,7 @@ document.addEventListener('alpine:init', () => { // Only when opened as an FK "Add" iframe dialog: report the new record to the opener and stop // here (the parent closes the dialog and selects it). Otherwise fall through to the list. if (this.isDialog) { + this.clearPristine(); this.emitCreated(newId); return; } @@ -593,11 +600,15 @@ document.addEventListener('alpine:init', () => { this.id = newId; this.mode = 'edit'; this.detailDefs = App.detailsFor('${name}'); + // The write landed - clear the guard before the page navigates itself, or it would veto + // its own route change (issue #7359). loadRecord re-takes the snapshot. + this.clearPristine(); window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(newId) + '/edit'); await this.loadRecord(); return; } } + this.clearPristine(); this.navigateBack('/${name}'); } catch (e) { this.applyApiError(e, { @@ -760,13 +771,24 @@ document.addEventListener('alpine:init', () => { #end cancel() { - // Only when opened as an FK "Add" iframe dialog: ask the parent to close it instead of navigating - // this iframe to a list. - if (this.isDialog) { - this.emitClose(); - return; - } - this.navigateBack('/${name}'); + // A form with unsaved edits asks before it is abandoned (issue #7359); a clean one leaves + // silently, as before. + this.guardExit(() => { + // Only when opened as an FK "Add" iframe dialog: ask the parent to close it instead of + // navigating this iframe to a list. + if (this.isDialog) { + this.clearPristine(); + this.emitClose(); + return; + } + this.navigateBack('/${name}'); + }); + }, + + // The guard dialog's Discard when the user is STAYING on the page: put the form back to what the + // server holds. + async reloadForm() { + await this.loadRecord(); }, // Preview -> the editable form for the same record (returnTo carried over). Inside a detail diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template index 62ec1947804..0f98ea14dcc 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template @@ -45,10 +45,12 @@ 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 - + + + @@ -482,8 +484,10 @@ - - + + @@ -574,4 +578,28 @@ #end + +
+
+
+

+ +
+
+

+
+
+ + + +
+
+
+ diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/translations.json.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/translations.json.template index 61ecf37459e..dd39fbb8d96 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/translations.json.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/translations.json.template @@ -45,7 +45,10 @@ "noDataTitle": "No data", "reportNoRows": "The {{name}} report returned no rows.", "detailSelectRecord": "Select a record to get a list of it's details.", - "immutable": "This record is read-only in its current status - corrections go through the workflow." + "immutable": "This record is read-only in its current status - corrections go through the workflow.", + "unsavedChanges": "Unsaved changes", + "unsavedChangesConfirm": "This form has changes that have not been saved yet.", + "saved": "Saved" }, "defaults": { "yes": "Yes", @@ -92,6 +95,10 @@ "update": "Update", "delete": "Delete", "cancel": "Cancel", + "discard": "Discard", + "keepEditing": "Keep editing", + "saveAndLeave": "Save and leave", + "saveAndContinue": "Save and continue", "close": "Close", "items": "Items", "description": "Description", diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaUnsavedChangesIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaUnsavedChangesIT.java new file mode 100644 index 00000000000..b73864b2a72 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaUnsavedChangesIT.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Every generated form guards its unsaved changes (dirigible #7359). + * + *

+ * The guard is one mechanism in the shared runtime - a snapshot of the save payload plus a veto on + * the route change - but it only protects a form that actually takes the snapshot and routes its + * exits through it. A template that does neither renders a page which looks exactly like a guarded + * one and still drops the edit on Back, which is how all six surfaces came to behave that way at + * once. The full journey is driven in a browser by {@code DependsOnHarmoniaIT}; this is the sweep + * that keeps a surface from being left behind (or written back to a direct navigation), including + * the self-service forms nobody generates in the browser suite. + */ +class HarmoniaUnsavedChangesIT { + + private static final String UI_BASE = "/META-INF/dirigible/template-application-ui-harmonia-java/ui/"; + + /** Every generated page component that owns a header form the user can edit. */ + private static final List PAGES = List.of("perspective/manage/form-page.js.template", + "perspective/document/document-page.js.template", "my/my-form-page.js.template", "my/my-document-page.js.template", + "partner/partner-form-page.js.template", "partner/partner-document-page.js.template"); + + /** Their views - where the marker and the one guard dialog are rendered. */ + private static final List VIEWS = List.of("perspective/manage/form-view.html.template", + "perspective/document/document-view.html.template", "my/my-form-view.html.template", "my/my-document-view.html.template", + "partner/partner-form-view.html.template", "partner/partner-document-view.html.template"); + + /** + * Both halves are needed and neither implies the other: a page that never snapshots is never dirty + * (the guard is silently inert), and a page that snapshots but navigates directly is worse than + * before - the shared router guard vetoes its own exit and the button does nothing. + */ + @Test + void everyEditableFormPageSnapshotsAndGuardsItsExits() throws Exception { + for (String page : PAGES) { + String content = read(UI_BASE + page); + assertTrue(content.contains("this.markPristine()"), page + " never snapshots what it loaded - it can never report an edit"); + assertTrue(content.contains("this.guardExit(") || content.contains("this.confirmLeave("), + page + " leaves the page without asking - an unsaved edit is dropped silently"); + } + } + + /** + * The dialog is what the guard calls; a page whose view does not render it vetoes the exit and then + * shows nothing, which traps the user on the form. + */ + @Test + void everyFormViewRendersTheGuardDialogAndTheMarker() throws Exception { + for (String view : VIEWS) { + String content = read(UI_BASE + view); + assertTrue(content.contains(":data-open=\"leaveOpen\""), view + " renders no unsaved-changes dialog for the guard to open"); + assertTrue(content.contains("leaveKeepEditing()") && content.contains("leaveDiscard()") && content.contains("leaveSave()"), + view + " does not offer all three answers (keep editing / discard / save)"); + assertTrue(content.contains("x-show=\"isDirty()\""), view + " never marks the form as having unsaved changes"); + } + } + + /** + * The shared half: the registry the pages register with and the veto itself. Restating either in a + * template is how a second, drifting mechanism starts. + */ + @Test + void theSharedRuntimeCarriesTheGuard() throws Exception { + String app = read("/META-INF/dirigible/application-core/shell/js/app.js"); + assertTrue(app.contains("App.leaveGuard"), "the shared runtime declares no leave guard"); + assertTrue(app.contains("globalHandlers"), + "the guard does not hook Pinecone's global handler - an in-app route change is not vetoed"); + assertTrue(app.contains("beforeunload"), "a reload / closed tab is not guarded"); + + String base = read("/META-INF/dirigible/application-core/shell/js/components/pages/basePage.js"); + assertTrue(base.contains("markPristine()") && base.contains("isDirty()"), "the shared page mixin carries no dirty state"); + } + + private static String read(String resource) throws IOException { + try (InputStream content = HarmoniaUnsavedChangesIT.class.getResourceAsStream(resource)) { + assertNotNull(content, "Missing resource " + resource); + return new String(content.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/DependsOnHarmoniaTestProject.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/DependsOnHarmoniaTestProject.java index 94f7f9d7c31..a10a81e77b7 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/DependsOnHarmoniaTestProject.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/DependsOnHarmoniaTestProject.java @@ -81,6 +81,8 @@ public void verify() { // watcher that re-filters the City options. browser.assertElementExistByAttributePatternAndText(HtmlElementType.BUTTON, HtmlAttribute.ROLE, "combobox", "Bulgaria"); + verifyUnsavedChangesGuard(); + // City depends on Country: opening it now must offer only Bulgaria's cities. The offered // options are asserted on the option element itself, not on a : since Harmonia 2.7 an // option renders its label inside a text COLUMN (a span wrapping the label span, so a @@ -92,4 +94,34 @@ public void verify() { browser.assertElementExistByAttributePatternAndText(HtmlElementType.DIV, HtmlAttribute.ROLE, "option", "Varna"); browser.assertElementDoesNotExistsByTypeAndContainsText(HtmlElementType.SPAN, "Milano"); } + + /** + * The unsaved-changes guard (dirigible #7359), on the form the user is standing on. + * + *

+ * Until it existed every exit from an edited form - Back to list, Cancel, a sidebar entry, the + * browser's own Back button - dropped the edit without a word, and nothing in the page said the + * form was dirty at all. The mechanism lives in the SHARED runtime (basePage snapshots the save + * payload, App.leaveGuard vetoes the route change) and is wired into every generated form, so it + * can only be proven in a browser: a snapshot taken at the wrong moment, a member Alpine cannot + * resolve in the view's scope, or a dialog Harmonia never opens all render as a page that looks + * exactly right and still loses the edit. + * + *

+ * The picked Country is a real change against the create-mode snapshot, so at this point the form + * is dirty. Keep editing must leave it exactly as it was - which is the whole point of asking. + */ + private void verifyUnsavedChangesGuard() { + browser.assertElementExistsByTypeAndContainsText(HtmlElementType.SPAN, "Unsaved changes"); + + browser.clickOnElementWithText(HtmlElementType.BUTTON, "Back to list"); + + // The dialog, not the list: the exit was vetoed and the user is being asked. + browser.assertElementExistsByTypeAndContainsText("h2", "Unsaved changes"); + browser.assertElementExistsByTypeAndContainsText(HtmlElementType.SPAN, "Save and leave"); + browser.clickOnElementWithText(HtmlElementType.BUTTON, "Keep editing"); + + // Still on the form, and the edit is still there. + browser.assertElementExistByAttributePatternAndText(HtmlElementType.BUTTON, HtmlAttribute.ROLE, "combobox", "Bulgaria"); + } }