From 3a2462ada9dedac622b7f5cce04ffeeb86070138 Mon Sep 17 00:00:00 2001 From: Nedelcho Delchev Date: Mon, 5 Oct 2026 11:18:05 +0300 Subject: [PATCH] A depends-on picker no longer offers the unnarrowed list while the page loads `loadOptions()` fetched the whole target table for every dropdown and the cascade narrowed it only once the record load had finished, so a depends-on picker spent the entire init offering rows its trigger goes on to refuse - a user quick enough to pick one got a server rejection for a choice the page had just shown as valid. The per-property fetch moves into its own `loadAllOptions()`. For a depends-on dropdown `loadOptions(deferDependsOn)` calls the new `refreshOptions()` instead - narrowed while the trigger holds a value, whole while it does not - and `init()` passes `deferDependsOn` so the list is decided exactly once, after the record load or the URL prefill has put the trigger in the form, by the same catch-up that registers the watcher (#7604). No trigger at all means the whole list; clearing the trigger returns to it. The init that the picker was waiting out also got shorter: `ensureFilteredCurrent` resolved every stored off-filter key with its own awaited GET, so a MULTISELECT holding a few hundred keys cost that many serial round trips before the page could continue. They now go out together through `Promise.all`. Fixes #7669 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/docs/harmonia-ui.md | 1 + .../README.md | 6 +- .../document/document-page.js.template | 65 +++++++++++++----- .../perspective/manage/form-page.js.template | 66 ++++++++++++++----- 4 files changed, 102 insertions(+), 36 deletions(-) diff --git a/.claude/docs/harmonia-ui.md b/.claude/docs/harmonia-ui.md index 4d64f172205..d837ac2410f 100644 --- a/.claude/docs/harmonia-ui.md +++ b/.claude/docs/harmonia-ui.md @@ -49,3 +49,4 @@ The runtime UI stack for generated applications: they render as a self-contained - **A BPM task's name travels with its translation key - the shell never derives one.** The generated `.bpmn` declares its module's catalog once on the `` element (`taskLabelCatalog`, minted by `IntentNaming.processTaskCatalog`); `BpmInboxEndpoint` serves every task a `nameKey`/`processDefinitionNameKey`; the client renders `T(task.nameKey, task.name)` through the `processTasks` store. Do not reintroduce a per-project `processTaskKeys` reverse map. - **A `dependsOn` watcher is registered BEFORE the catch-up, never after the awaited loads (#7604).** The generated document and form pages used to register `$watch('form.')` only after `await loadOptions()` / `loadHeader()`, so a trigger picked during the page's first load - seconds wide on a tenant whose shell issues one GET per open Inbox task - was never seen by any watcher, and the init-time catch-up ran with `adjust=false` (filter the options, write nothing): the customer was picked, the invoice's Language stayed empty and the PDF printed in the wrong language. The order is now watcher first, then one catch-up for a trigger already in the form, with `adjust` decided by WHO put it there: a value the page itself hydrated (the stored record on edit/preview, a URL prefill) only filters the options - a stored value is never touched - while a value that arrived any other way during the loads is the user's pick and applies its prefill. Registering the watcher before the catch-up leaves no gap between the two. Both templates that carry the shape (`document-page.js.template`, `form-page.js.template`) follow it; a new page with a trigger watcher must too. +- **A `dependsOn` picker never shows the unnarrowed list, not even while the page loads (#7669).** `loadOptions()` used to fetch the whole target table for every dropdown, the cascade narrowing it only after the record load finished - so the picker spent the whole init offering rows the trigger goes on to refuse, and a user who picked one got a server rejection. The per-property fetch now lives in its own `loadAllOptions()`; `loadOptions(deferDependsOn)` calls `refreshOptions()` (narrowed while the trigger holds a value, whole while it does not) for a depends-on dropdown, and `init()` passes `deferDependsOn` so the list is decided once - after the record/URL prefill has put the trigger in the form - by the same catch-up that registers the watcher. No trigger at all means the whole list, and clearing the trigger returns to it. The same init also stopped resolving off-filter stored keys one `await` at a time: `ensureFilteredCurrent` now issues them together through `Promise.all`, so a MULTISELECT holding a few hundred keys costs one burst instead of a few hundred serial round trips the page waited out. diff --git a/components/template/template-application-ui-harmonia-java/README.md b/components/template/template-application-ui-harmonia-java/README.md index 6b8a6bc8795..3a5934d0394 100644 --- a/components/template/template-application-ui-harmonia-java/README.md +++ b/components/template/template-application-ui-harmonia-java/README.md @@ -26,7 +26,11 @@ the same for document headers plus a **metadata-driven** cascade in the line-ite dialog (`detail-register.js.template` emits `editColumns[].dependsOn`; filtered options live in a separate `draftOptions` store so the items table's label resolution keeps the full option set). The trigger's controller URL is precomputed as -`widgetDependsOnControllerUrl` by `service-generate`'s `ModelParameterProcessor`. +`widgetDependsOnControllerUrl` by `service-generate`'s `ModelParameterProcessor`. A +dependent dropdown is never filled from the unnarrowed table: each picker's own +`loadAllOptions()` holds the full fetch, `refreshOptions()` picks narrowed or +whole by the trigger's current value, and `init()` defers that choice until the record (or +URL prefill) has put the trigger in the form (#7669). **Multi-language data** is wired through a single per-user flag: the Settings page's **Region & Language** picker (rendered from the generated `config.js` `languages`, hidden for a single language) writes the shared `locale` Alpine store 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 27570d7df95..96c6bd20d18 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 @@ -378,7 +378,7 @@ document.addEventListener('alpine:init', () => { }); } - await this.loadOptions(); + await this.loadOptions(true); if (this.isEdit || this.isPreview) { this.state = 'loading'; @@ -419,6 +419,10 @@ document.addEventListener('alpine:init', () => { // the catch-up leaves no gap between the two for a pick to fall into. this.${dollar}watch('form.${property.widgetDependsOnProperty}', (v) => { if (v != null && v !== '') this.applyDependsOn${property.name}(v, true); +#if($property.widgetType == "DROPDOWN") + // Trigger cleared: the narrowed list describes nothing - back to the whole of it. + else this.loadAllOptions${property.name}(); +#end }); if (this.form.${property.widgetDependsOnProperty} != null && this.form.${property.widgetDependsOnProperty} !== '') { const pickedDuringLoad${property.name} = !this.isEdit && !this.isPreview && this.queryParam('${property.widgetDependsOnProperty}') === null; @@ -428,6 +432,13 @@ document.addEventListener('alpine:init', () => { if (pickedDuringLoad${property.name}) await this.applyDependsOn${property.name}(this.form.${property.widgetDependsOnProperty}, true); #end } +#if($property.widgetType == "DROPDOWN") + else { + // No trigger to narrow by: the whole list is the right one, and only now is that + // known - loadOptions above deliberately left this picker empty (#7669). + await this.loadAllOptions${property.name}(); + } +#end #end #end // Create mode: the blank form plus whatever the URL and the depends-on prefills put in it is @@ -537,9 +548,25 @@ document.addEventListener('alpine:init', () => { await this.loadHeader(); }, - async loadOptions() { + async loadOptions(deferDependsOn) { +#foreach($property in $properties) +#if(($property.widgetType == "DROPDOWN" || $property.widgetType == "DOCUMENT_STATUS" || $property.widgetType == "MULTISELECT") && $property.aggregate != "true") +#if($property.widgetDependsOnProperty && $property.widgetDependsOnControllerUrl && $property.widgetType == "DROPDOWN" && $property.aggregate != "true") + // A Depends-On picker's list is the cascade's to decide (#7669): loading the whole of it + // here put rows in the chooser that the trigger goes on to refuse. At init the trigger is + // not in the form yet, so init() passes deferDependsOn and decides after the record load. + if (!deferDependsOn) await this.refreshOptions${property.name}(); +#else + await this.loadAllOptions${property.name}(); +#end +#end +#end + }, #foreach($property in $properties) #if(($property.widgetType == "DROPDOWN" || $property.widgetType == "DOCUMENT_STATUS" || $property.widgetType == "MULTISELECT") && $property.aggregate != "true") + + // The whole option list of ${property.name}, unnarrowed. + async loadAllOptions${property.name}() { try { #if($property.widgetOptionsFilterBy) // Static option filter (intent `where:`): the chooser lists only matching rows. @@ -565,17 +592,19 @@ document.addEventListener('alpine:init', () => { } catch (e) { console.error('[${name}DocumentPage] failed to load options for ${property.name}', e); } -#end -#end -#foreach($property in $properties) + }, #if($property.widgetDependsOnProperty && $property.widgetDependsOnControllerUrl && $property.widgetType == "DROPDOWN" && $property.aggregate != "true") - // Re-apply the Depends-On filter after a full reload (Refresh / Add-new keep the cascade). - if (this.form.${property.widgetDependsOnProperty} != null && this.form.${property.widgetDependsOnProperty} !== '') { - await this.applyDependsOn${property.name}(this.form.${property.widgetDependsOnProperty}, false); - } + + // The right list for the trigger as it now stands: narrowed while the trigger has a value, + // whole while it has none (a Refresh, a cleared trigger). + async refreshOptions${property.name}() { + const trigger${property.name} = this.form.${property.widgetDependsOnProperty}; + if (trigger${property.name} != null && trigger${property.name} !== '') await this.applyDependsOn${property.name}(trigger${property.name}, false); + else await this.loadAllOptions${property.name}(); + }, +#end #end #end - }, // "Add new " from a header FK combobox: open the target's own create page in the shared // related-create iframe dialog; on save, reload that field's options and select the new record. @@ -1007,14 +1036,16 @@ document.addEventListener('alpine:init', () => { if (!values.length) return; const opts = this['options' + prop]; if (!Array.isArray(opts)) return; - for (const value of values) { - if (opts.some(o => String(o.value) === String(value))) continue; - try { - const row = await App.services.api.get(url + '/' + encodeURIComponent(value), { baseUrl: '' }); - if (row && row[key] != null) opts.unshift({ value: row[key], text: row[text] }); - } catch (e) { + // All of them in flight at once: a MULTISELECT holding a few hundred off-filter keys used + // to resolve them one await at a time, and the page's whole init waited out the chain (#7669). + const missing = values.filter(v => !opts.some(o => String(o.value) === String(v))); + const rows = await Promise.all(missing.map(v => App.services.api.get(url + '/' + encodeURIComponent(v), { baseUrl: '' }) + .catch(e => { console.error('[${name}DocumentPage] failed to resolve the filtered current option for ' + prop, e); - } + return null; + }))); + for (const row of rows) { + if (row && row[key] != null) opts.unshift({ value: row[key], text: row[text] }); } }, 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 d6e674d4157..99ac1412fb2 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 @@ -247,7 +247,7 @@ document.addEventListener('alpine:init', () => { this.relatedDefs = this.id != null ? App.relatedFor('${name}') : []; #if($hasDropdowns) - await this.loadOptions(); + await this.loadOptions(true); #end if (this.isEdit || this.isPreview) { @@ -281,6 +281,10 @@ document.addEventListener('alpine:init', () => { // the catch-up leaves no gap between the two for a pick to fall into. this.${dollar}watch('form.${property.widgetDependsOnProperty}', (v) => { if (v != null && v !== '') this.applyDependsOn${property.name}(v, true); +#if($property.widgetType == "DROPDOWN") + // Trigger cleared: the narrowed list describes nothing - back to the whole of it. + else this.loadAllOptions${property.name}(); +#end }); if (this.form.${property.widgetDependsOnProperty} != null && this.form.${property.widgetDependsOnProperty} !== '') { const pickedDuringLoad${property.name} = !this.isEdit && !this.isPreview && this.queryParam('${property.widgetDependsOnProperty}') === null; @@ -290,6 +294,13 @@ document.addEventListener('alpine:init', () => { if (pickedDuringLoad${property.name}) await this.applyDependsOn${property.name}(this.form.${property.widgetDependsOnProperty}, true); #end } +#if($property.widgetType == "DROPDOWN") + else { + // No trigger to narrow by: the whole list is the right one, and only now is that + // known - loadOptions above deliberately left this picker empty (#7669). + await this.loadAllOptions${property.name}(); + } +#end #end #end #if($hasCalculated) @@ -382,9 +393,25 @@ document.addEventListener('alpine:init', () => { }, #if($hasDropdowns) - async loadOptions() { + async loadOptions(deferDependsOn) { #foreach($property in $properties) #if($property.widgetType == "DROPDOWN" || $property.widgetType == "DOCUMENT_STATUS" || $property.widgetType == "MULTISELECT") +#if($property.widgetDependsOnProperty && $property.widgetDependsOnControllerUrl && $property.widgetType == "DROPDOWN" && !$property.widgetDependsOnHeader) + // A Depends-On picker's list is the cascade's to decide (#7669): loading the whole of it + // here put rows in the chooser that the trigger goes on to refuse. At init the trigger is + // not in the form yet, so init() passes deferDependsOn and decides after the record load. + if (!deferDependsOn) await this.refreshOptions${property.name}(); +#else + await this.loadAllOptions${property.name}(); +#end +#end +#end + }, +#foreach($property in $properties) +#if($property.widgetType == "DROPDOWN" || $property.widgetType == "DOCUMENT_STATUS" || $property.widgetType == "MULTISELECT") + + // The whole option list of ${property.name}, unnarrowed. + async loadAllOptions${property.name}() { try { #if($property.widgetOptionsFilterBy) // Static option filter (intent `where:`): the chooser lists only matching rows. @@ -415,18 +442,19 @@ document.addEventListener('alpine:init', () => { } catch (e) { console.error('[${name}FormPage] failed to load options for ${property.name}', e); } -#end -#end -#foreach($property in $properties) + }, #if($property.widgetDependsOnProperty && $property.widgetDependsOnControllerUrl && $property.widgetType == "DROPDOWN" && !$property.widgetDependsOnHeader) - // Re-apply the Depends-On filter after a full reload (Refresh / Add-new keep the cascade). - if (this.form.${property.widgetDependsOnProperty} != null && this.form.${property.widgetDependsOnProperty} !== '') { - await this.applyDependsOn${property.name}(this.form.${property.widgetDependsOnProperty}, false); - } + + // The right list for the trigger as it now stands: narrowed while the trigger has a value, + // whole while it has none (a Refresh, a cleared trigger). + async refreshOptions${property.name}() { + const trigger${property.name} = this.form.${property.widgetDependsOnProperty}; + if (trigger${property.name} != null && trigger${property.name} !== '') await this.applyDependsOn${property.name}(trigger${property.name}, false); + else await this.loadAllOptions${property.name}(); + }, +#end #end #end - }, - // Hierarchy-aware picker options (intent `hierarchy:` + `leafOnly:`): depth-first order, // em-space indentation per depth, and optionally leaves only (nodes nobody parents). The @@ -454,14 +482,16 @@ document.addEventListener('alpine:init', () => { if (!values.length) return; const opts = this['options' + prop]; if (!Array.isArray(opts)) return; - for (const value of values) { - if (opts.some(o => String(o.value) === String(value))) continue; - try { - const row = await App.services.api.get(url + '/' + encodeURIComponent(value), { baseUrl: '' }); - if (row && row[key] != null) opts.unshift({ value: row[key], text: row[text] }); - } catch (e) { + // All of them in flight at once: a MULTISELECT holding a few hundred off-filter keys used + // to resolve them one await at a time, and the page's whole init waited out the chain (#7669). + const missing = values.filter(v => !opts.some(o => String(o.value) === String(v))); + const rows = await Promise.all(missing.map(v => App.services.api.get(url + '/' + encodeURIComponent(v), { baseUrl: '' }) + .catch(e => { console.error('[${name}FormPage] failed to resolve the filtered current option for ' + prop, e); - } + return null; + }))); + for (const row of rows) { + if (row && row[key] != null) opts.unshift({ value: row[key], text: row[text] }); } },