Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/docs/harmonia-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<process>` 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.<trigger>')` 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<Name>()`; `loadOptions(deferDependsOn)` calls `refreshOptions<Name>()` (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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Name>()` holds the full fetch, `refreshOptions<Name>()` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ document.addEventListener('alpine:init', () => {
});
}

await this.loadOptions();
await this.loadOptions(true);

if (this.isEdit || this.isPreview) {
this.state = 'loading';
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 <related>" 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.
Expand Down Expand Up @@ -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] });
}
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] });
}
},

Expand Down
Loading