From 78b5a24c7d5435b39b7fec81d8e1ee6a62c24aa2 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 13 Aug 2026 16:00:10 +0300 Subject: [PATCH] fix(templates): a BPM task form renders its labels through its module i18n catalog (#6692) Every generated BPM task form rendered in English - the title, the status stepper, the field labels and the button captions - while the application shell pages around it were fully translated. The catalogs were never the problem: the same generation pass that renders the page already emits `i18n/en-US/
.form.json` with a key per label, and modules already ship their translated copies next to it. The page simply never consumed them: it baked every label in as a literal and did not load the translator at all. It does now, following the standalone report page's precedent: - the page loads the shared `application-core/shell/js/services/i18n.js` and, having no SPA shell to read it from, bootstraps `App.config.projectName` in a one-line inline script first - without that, i18n.js fetches only the platform `application-core` chrome catalog and every module-authored key silently stays English; - the title, paragraph, link, field labels, read-only detail labels and button captions bind through `T(':.t.', '')`, emitted by one `#tlabel` macro so every control site is identical, and the English literal stays as the element's own text so a control the catalog never keyed (a hand-authored form without a label) is unchanged; - a status step keeps its untranslated `label` - that is what the record's status value is MATCHED against - and gains a translated `title`, which is what shows; - the two submit outcome messages resolve through the catalog's `dialogs` section. Everything is resolved at generation time: the translation id was assigned to the model by the catalog-emitting pass, so nothing is derived in the browser, and an untranslated key degrades to the baked English literal exactly as elsewhere in the stack. An authored label may contain an apostrophe, which would close the JS string literal the `T()` call sits in and break the whole Alpine expression, so both the interpolated literal and the KEY derived from it are escaped - `translationId` strips only spaces and `_ . :`, so `Customer's Note` keys as `Customer'sNote`. Covered by `IntentEngineIT.task_form_renders_its_labels_through_the_module_catalog`, which asserts both halves - the labels land in the emitted catalog, and the generated page resolves them through it. `BPMStarterTemplateIT` (a real browser filling and submitting a generated form) stays green, which is what proves the new Alpine bindings actually evaluate. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + .../template-form-builder-harmonia/README.md | 35 +++++++- .../ui/form.js.template | 9 +- .../ui/index.html.template | 59 ++++++++----- .../integration/tests/api/IntentEngineIT.java | 83 +++++++++++++++++++ 5 files changed, 161 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61506ba870..28da3e53c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,6 +206,7 @@ The runtime UI stack for generated applications: they render as a self-contained - **Date/time widgets need conversion both ways.** The form `toPayload()` turns an HTML `date`/`datetime-local` value into a full ISO instant (`…Z`) so a Jackson `java.time.Instant`/`Timestamp` field binds (empty→`null`, a bare `TIME` passes through); `toDateInput()` slices the backend's ISO value back to what the widget expects on edit. Mirrors the AngularJS stack's `new Date(value)`. - **Master-detail is registry-driven.** A master page renders one `detailPanel` per `App.detailsFor()` entry; each detail self-registers via `App.registerDetail(...)` (relative `apiPath`), so masters never enumerate details at generation time. The detail list filters via the controller's `?=` query (built into the reused rest-java controller for `*_DETAILS` layouts). - **The `.form` runs the existing AngularJS `code` via compat shims, and the page is self-contained.** `template-form-builder-harmonia` runs the `.form` `code` as the body of `formController(ctx)` (`ctx.{model, params, http, task, notify, close}`) and defines `$scope`/`$http`/`NotificationHub`/`DialogHub` shims so intent-generated AngularJS `.form` code runs **unchanged** (no migration needed). The page loads only `form.js` + its own minimal fetch client (no `window.App`), because a BPM task form opens standalone in an iframe where the SPA shell assets are absent — an earlier `../../js/...` reference 404'd and left `App` undefined. +- **A standalone page must bootstrap `App.config.projectName` before loading `i18n.js`, or every module-authored label silently stays English.** `i18n.js` reads the project namespace off `App.config.projectName` — which only the SPA shell sets — and without it fetches just the platform `application-core` chrome catalog, so the page's own `.t.*` keys never resolve. The failure is invisible: the baked English literals render, no console error, and every shell page around the iframe IS translated. Both standalone pages (the report page and, since [#6692](https://github.com/eclipse-dirigible/dirigible/issues/6692), the task form) therefore set it in a one-line inline script before the ` + + + 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 1573be1d1b..2abfadae1a 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 @@ -2517,6 +2517,89 @@ private void assertSettings() { "settings should default candidateGroupsExtra to ADMINISTRATOR"); } + @Test + void task_form_renders_its_labels_through_the_module_catalog() { + // A BPM task form is a standalone page (no SPA shell), which is why its content used to render + // in English while the shell pages around it were translated: it never loaded the translator and + // baked every label in as a literal. The catalog it needs is the one this same generation emits. + String yaml = """ + name: invoices + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 50 } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, required: true, length: 20 } + - { name: total, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + processes: + - name: InvoiceApproval + trigger: { onCreate: Invoice } + steps: + - { name: approve, kind: userTask, args: { assignee: manager, form: ApproveInvoice } } + - { name: decide, kind: decision, args: { if: "action == 'approve'", then: activate, else: cancel } } + - { name: activate, kind: serviceTask, args: { setRelationField: Status, value: 2, next: done } } + - { name: cancel, kind: serviceTask, args: { setRelationField: Status, value: 3, next: end } } + - { name: done, kind: end } + forms: + - name: ApproveInvoice + forEntity: Invoice + fields: [number, total] + actions: [approve, reject] + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: APPROVED } + - { id: 3, name: CANCELLED } + """; + writeIntent(yaml); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + generateFromModel("template-form-builder-harmonia/template/template.js", "ApproveInvoice.form"); + + // The catalog the translate action emits, keyed by the form's own prefix: every label is in it. + String catalog = contentOf("i18n/en-US/ApproveInvoice.form.json"); + assertTrue(catalog.contains("\"ApproveInvoice-form\""), "the catalog should be keyed by the form translation prefix"); + assertTrue(catalog.contains("\"Number\": \"Number\"") && catalog.contains("\"Total\": \"Total\""), + "the field labels should land in the catalog, got: " + catalog); + assertTrue(catalog.contains("\"Approve\": \"Approve\"") && catalog.contains("\"Reject\": \"Reject\""), + "the action button captions should land in the catalog, got: " + catalog); + assertTrue(catalog.contains("\"DRAFT\": \"DRAFT\"") && catalog.contains("\"APPROVED\": \"APPROVED\""), + "the status step labels should land in the catalog, got: " + catalog); + + // ... and the generated page consumes it: the translator is loaded with this project's namespace + // bootstrapped (the page has no window.App to read it from), and every label binds through T() + // with the English literal as the fallback. + String index = contentOf("gen/ApproveInvoice/forms/ApproveInvoice/index.html"); + assertTrue(index.contains("/services/web/application-core/shell/js/services/i18n.js"), + "the standalone form must load the shared translator"); + assertTrue(index.contains("App.config.projectName = '" + PROJECT + "'"), + "the form must bootstrap its catalog namespace - i18n.js reads it at load"); + String prefix = "T('" + PROJECT + ":ApproveInvoice-form.t."; + assertTrue(index.contains("tracking-tight\" x-text=\"" + prefix), "the form title should resolve through the catalog"); + assertTrue(index.contains("x-text=\"" + prefix + "Number', 'Number')\""), "a field label should resolve through the catalog"); + assertTrue(index.contains("x-text=\"" + prefix + "Approve', 'Approve')\""), + "an action button caption should resolve through the catalog"); + // The step's `label` stays the untranslated seed name - it is what the record's status value is + // matched against - and the displayed `title` is the translated one. + assertTrue(index.contains("title: " + prefix + "DRAFT', 'DRAFT')"), "a status step should carry its translated title"); + assertTrue(index.contains("{ label: 'DRAFT'"), "the step label must stay untranslated for the active-status match"); + assertTrue(index.contains("x-text=\"step.title\""), "the step indicator should render the translated title"); + + String formJs = contentOf("gen/ApproveInvoice/forms/ApproveInvoice/form.js"); + assertTrue(formJs.contains("T('" + PROJECT + ":ApproveInvoice-form.dialogs.successMsg'"), + "the submit outcome messages should resolve through the catalog"); + } + @Test void settings_overrides_skip_generation_and_are_preserved() { writeIntent(INTENT_YAML);