From e3f7b38f8eb2192725667d87f7708698e87a2903 Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 4 Aug 2026 18:43:45 +0300 Subject: [PATCH] fix(harmonia): detail-panel child forms open in the shared iframe dialog, never a main-pane navigation (#6509) Adding or editing a composition child (a company's bank account) from the shared detail panel navigated the main pane to the child's form and back - re-mounting the parent form, which reloads from the server, so every unsaved field the user had filled was silently discarded. The FK combobox's "Add new" already had the right shape: the related-record iframe dialog. The detail panel's addRow/editRow/previewRow and the calendar panel's event/day clicks now open the child's own form page in that dialog (?embedded=1#//...?embedded=1&dialog=1) with the master FK preset; on save the dialog closes and the panel reloads its rows while the parent form keeps its state. Dialog-mode EDIT saves post the new harmonia.entity.updated message (baseFormPage.emitSaved; the related store routes created and updated to the same close+callback path), and a preview's Edit stays inside the dialog. The returnTo def plumbing is removed - nothing navigates anymore. The shared-runtime half (detailPanel/related/baseFormPage) applies with the platform jar alone; the generated form page's emitSaved branch and the register's returnTo removal reach deployed apps on regeneration. IntentEngineIT locks the emission: the detail register carries no returnTo and the generated form page reports a dialog-mode edit save to the opener. Co-Authored-By: Claude Fable 5 --- .../shell/js/components/detailPanel.js | 38 ++++++++++++------- .../shell/js/components/pages/baseFormPage.js | 10 +++++ .../shell/js/stores/related.js | 8 ++-- .../perspective/manage/form-page.js.template | 14 ++++++- .../master/detail-register.js.template | 1 - .../integration/tests/api/IntentEngineIT.java | 10 +++++ 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js index 11d3a226584..9fae7f387a1 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js @@ -16,10 +16,11 @@ * passing the panel definition and the selected master id. The panel lists that detail's * rows filtered to the master via the controller's `?=` query (which * the generated REST controller supports for MANAGE_DETAILS/LIST_DETAILS), supports delete, - * and routes create/edit to the detail's own generated form page (FK + returnTo preset). + * and opens create/edit/preview in the shared related-record iframe dialog (never a main-pane + * navigation - the master form may hold unsaved edits). * * `def` shape (from App.registerDetail): { entity, apiPath, masterEntityId, label, - * columns: [{ name }], returnTo }. apiPath is relative to App.config.restBase (the api client + * columns: [{ name }] }. apiPath is relative to App.config.restBase (the api client * prepends it), so detail calls pass no baseUrl override. * * A def carrying `calendar: { start, end?, title?, color?, view, range }` (a composition child @@ -137,20 +138,29 @@ function detailPanel(def, masterId) { this.refreshIcons(); }, - // Create/edit route to the detail's own form page, carrying the FK + a returnTo back here. + // The detail's own form page, opened in the shared related-record iframe DIALOG - never a + // main-pane navigation. The parent form may hold unsaved edits, and navigating away silently + // discards them (observed live: fill a record, add a child, come back to empty fields). The + // child form runs the same SPA embedded (?embedded=1 hides its chrome) in dialog mode + // (dialog=1 - save/cancel post messages to the opener instead of navigating); on save the + // panel reloads its rows while the parent form keeps its state. + openForm(route, title) { + Alpine.store('related').create(window.location.pathname + '?embedded=1#' + route, title, () => this.load()); + }, addRow() { const q = '?' + encodeURIComponent(this.def.masterEntityId) + '=' + encodeURIComponent(this.masterId) - + '&returnTo=' + encodeURIComponent(this.def.returnTo); - window.PineconeRouter.navigate('/' + this.def.entity + '/create' + q); + + '&embedded=1&dialog=1'; + this.openForm('/' + this.def.entity + '/create' + q, + (window.T ? T('application-core:shell.related.addNew', 'Add new') : 'Add new')); }, editRow(row) { - const q = '?returnTo=' + encodeURIComponent(this.def.returnTo); - window.PineconeRouter.navigate('/' + this.def.entity + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/edit' + q); + this.openForm('/' + this.def.entity + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/edit?embedded=1&dialog=1', + (window.T ? T(this.def.tkey, this.def.label) : this.def.label)); }, // Read-only view of the detail record (the routed form page in preview mode). previewRow(row) { - const q = '?returnTo=' + encodeURIComponent(this.def.returnTo); - window.PineconeRouter.navigate('/' + this.def.entity + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/preview' + q); + this.openForm('/' + this.def.entity + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/preview?embedded=1&dialog=1', + (window.T ? T(this.def.tkey, this.def.label) : this.def.label)); }, // --- embedded calendar (calendar defs only) ------------------------------------------------- @@ -220,16 +230,17 @@ function detailPanel(def, masterId) { }, // Event click -> edit the child; empty-day click -> create one with the master FK AND the // clicked date preset (the shared form presets any create query param whose name matches). + // Both open the shared iframe dialog (openForm) so the parent's unsaved edits survive. onEventClick(e) { const id = e && e.detail && e.detail.event ? e.detail.event.id : null; if (!id) return; - const q = '?returnTo=' + encodeURIComponent(this.def.returnTo); - window.PineconeRouter.navigate('/' + this.def.entity + '/' + encodeURIComponent(id) + '/edit' + q); + this.openForm('/' + this.def.entity + '/' + encodeURIComponent(id) + '/edit?embedded=1&dialog=1', + (window.T ? T(this.def.tkey, this.def.label) : this.def.label)); }, onDateClick(e) { const cal = this.def.calendar; let q = '?' + encodeURIComponent(this.def.masterEntityId) + '=' + encodeURIComponent(this.masterId) - + '&returnTo=' + encodeURIComponent(this.def.returnTo); + + '&embedded=1&dialog=1'; const d = e && e.detail ? e.detail.date : null; if (d instanceof Date && !isNaN(d.getTime())) { const p = n => String(n).padStart(2, '0'); @@ -237,7 +248,8 @@ function detailPanel(def, masterId) { if (e.detail.time) val += 'T' + e.detail.time; q += '&' + encodeURIComponent(cal.start) + '=' + encodeURIComponent(val); } - window.PineconeRouter.navigate('/' + this.def.entity + '/create' + q); + this.openForm('/' + this.def.entity + '/create' + q, + (window.T ? T('application-core:shell.related.addNew', 'Add new') : 'Add new')); }, // --- files panel (files defs only) ---------------------------------------------------------- diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/baseFormPage.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/baseFormPage.js index 5a942a67096..746fbdebb28 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/baseFormPage.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/baseFormPage.js @@ -151,6 +151,16 @@ function baseFormPage() { } catch (e) { /* cross-origin / standalone: nothing to notify */ } }, + // Tell the hosting dialog an EDIT saved (a detail-panel dialog's update) so it can close and + // reload the panel - navigating this iframe to a list nobody sees would strand the dialog open. + emitSaved(id) { + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'harmonia.entity.updated', id: id }, '*'); + } + } catch (e) { /* cross-origin / standalone: nothing to notify */ } + }, + // Ask the hosting dialog to close (embedded Cancel) instead of navigating this iframe to a list. emitClose() { try { diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/related.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/related.js index cd0edfa7dc7..28dffddcd70 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/related.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/related.js @@ -51,15 +51,17 @@ document.addEventListener('alpine:init', () => { }); }); -// The embedded create page posts a message to its parent: 'harmonia.entity.created' (with id) on -// save, or 'harmonia.dialog.cancel' when the user cancels. Route both to the related store. +// The embedded form posts a message to its parent: 'harmonia.entity.created' (with id) when a +// create saves, 'harmonia.entity.updated' when a detail-panel dialog's edit saves, or +// 'harmonia.dialog.cancel' when the user cancels. Created and updated take the same path - close +// the dialog and hand the id to the opener's callback (select the new record / reload the panel). window.addEventListener('message', (event) => { const data = event && event.data; if (!data) return; try { const store = window.Alpine && Alpine.store('related'); if (!store || !store.open) return; - if (data.type === 'harmonia.entity.created') store.handleCreated(data.id); + if (data.type === 'harmonia.entity.created' || data.type === 'harmonia.entity.updated') store.handleCreated(data.id); else if (data.type === 'harmonia.dialog.cancel') store.close(); } catch (e) { /* Alpine not ready / no store */ } }); 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 eb2e2368ed6..df902ab03da 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 @@ -493,6 +493,12 @@ document.addEventListener('alpine:init', () => { const payload = this.toPayload(); if (this.isEdit) { await App.services.api.put(this.apiPath + '/' + encodeURIComponent(this.id), payload); + // 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.emitSaved(this.id); + return; + } } else { const created = await App.services.api.post(this.apiPath, payload); // Only when opened as an FK "Add" iframe dialog: report the new record to the opener and stop @@ -620,8 +626,14 @@ document.addEventListener('alpine:init', () => { this.navigateBack('/${name}'); }, - // Preview -> the editable form for the same record (returnTo carried over). + // Preview -> the editable form for the same record (returnTo carried over). Inside a detail + // dialog the edit stays IN the dialog (embedded + dialog mode carried), so its save still + // reports to the opener instead of navigating the iframe to a list. goEdit() { + if (this.isDialog) { + window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(this.id) + '/edit?embedded=1&dialog=1'); + return; + } const ret = this.returnToParam(); window.PineconeRouter.navigate('/${name}/' + encodeURIComponent(this.id) + '/edit' + (ret ? '?returnTo=' + encodeURIComponent(ret) : '')); }, diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template index 66ea257ee45..a11b1681e7a 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template @@ -34,7 +34,6 @@ App.registerDetail('${masterEntity}', { apiPath: '/${javaPerspectiveName}/${name}Controller', masterEntityId: '${masterEntityId}', primaryKey: '${primaryKeysString}', - returnTo: '/${masterEntity}', #if($detailCalendar) // Rendered as an embedded x-h-calendar panel on the master page (intent view: calendar on a // composition child); the shared detailPanel maps the rows to events by these properties. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 9c43ca6b214..ecf8c31b1c5 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -1389,6 +1389,16 @@ void harmonia_form_page_generates_the_depends_on_runtime() { String copyRegister = contentOf("gen/orders/js/components/pages/Order/OrderCopy.detail.js"); assertTrue(copyRegister.contains("files: { readOnly: true }"), "a function: Snapshot child must register as a read-only files def, got: " + copyRegister); + + // Detail-panel children open in the shared iframe DIALOG, never a main-pane navigation - a + // navigation would discard the master form's unsaved edits (observed live: fill a record, + // add a child, come back to empty fields). The register therefore carries no returnTo + // route, and the generated form page reports a dialog-mode EDIT save to the opener. + assertFalse(copyRegister.contains("returnTo"), + "detail rows open in a dialog - the register must not carry a main-pane return route"); + String customerFormPage = contentOf("gen/orders/js/components/pages/Customer/CustomerFormPage.js"); + assertTrue(customerFormPage.contains("this.emitSaved(this.id)"), + "a dialog-mode edit save must report to the opener instead of navigating the iframe to a list"); String documentView = contentOf("gen/orders/views/Order/Order-document.html"); assertTrue(documentView.contains("openHref(row)"), "the files panel rows must offer the inline Open action for stored snapshot versions");