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.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<master>)` 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 `?<masterEntityId>=<id>` 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 `<tprefix>.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 `<script src=".../services/i18n.js">`. Two more rules the task-form conversion paid for: a value that is MATCHED against data (a status step's `label`, compared to the record's status) stays untranslated and gets a separate translated `title`, and an authored label must be escaped where it is interpolated into a `T()` call — **both the literal and the key derived from it**, since `translationId` strips only spaces and `_ . :`, so an apostrophe would close the JS string and break the whole Alpine expression.
- **Intent glue handlers are self-describing `@Component`s, not class-level `@Listener`/`@Scheduled`.** Those SDK annotations are `@Target(METHOD)`; the rollup/notification/integration/job templates in `template-application-events-java` were converted to `@Component implements MessageHandler/JobHandler` with `destination()`/`kind()`/`cron()` (matching the Trigger template) — class-level use fails `javac` with "annotation interface not applicable".
- **The full-stack model template MUST merge the schema layer.** `template.js` = `template-application-schema` + REST-java + Harmonia UI. The client-Java `JavaEntityManager` only *registers* an `@Entity` against an existing table — it never CREATES one; `TableCreateProcessor` (the schema sync) does. Drop the schema and a freshly generated app has **no tables** → CRUD + CSVIM seeds fail ("Table metadata was not found for table [...]"). It only *looked* fine when a prior AngularJS/schema generation had already created them ("table kept in place").
- **Process trigger writes ProcessId via a targeted single-column update (no event, no full row).** Starting a process on `onCreate` writes the instance id back; doing it through the normal `update()` republishes `<entity>-updated` and spuriously fires every onUpdate reaction (e.g. the member-email notification fired the instant a loan was created) — and even the silent `updateWithoutEvent()` was a **full-row merge of the trigger's stale snapshot**, which raced concurrent writes (line items recalculating the header totals milliseconds after create, a start-step status set) and silently reverted them. The trigger now uses `repository.updateProperty(id, "ProcessId", processId)` — an SDK `JavaRepository`/`JavaEntityStore` HQL mutation touching only the named column (same for a minted `businessKeyStrategy` field); no audit stamping, no events, nothing else to clobber. `updateProperty` is the sanctioned workflow/system write-back primitive — reserve it for system columns; user data keeps going through the generated repository's normal write path. The trigger guard is `ProcessId != null && !isBlank()` (an empty string from a form must not count as "already started").
Expand Down
35 changes: 31 additions & 4 deletions components/template/template-form-builder-harmonia/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,40 @@ runs unchanged. New forms can still be authored directly against the neutral `ct
input-time, input-color, button, and one level of `container-hbox` / `container-vbox`.
- **Fallback:** any other `controlId` (image, input-documents, input-combobox/select/radio
with feeds, table, stepIndicator, progress) renders as a labelled text input with a TODO.
- **Assets (self-contained):** the page loads only `form.js` + the Harmonia/Alpine/Lucide
webjars. `form.js` carries its own minimal fetch client (`harmoniaHttp`), so the form does
**not** depend on the SPA shell's `window.App`. This matters because a BPM task form is
opened standalone (an iframe/dialog with `?taskId=&processInstanceId=`), where the shell
- **Assets (self-contained):** the page loads `form.js` + the Harmonia/Alpine/Lucide webjars,
plus the two shared shell services it reuses by **absolute** URL (`services/format.js` and
`services/i18n.js`). `form.js` carries its own minimal fetch client (`harmoniaHttp`), so the
form does **not** depend on the SPA shell's `window.App`. This matters because a BPM task form
is opened standalone (an iframe/dialog with `?taskId=&processInstanceId=`), where the shell
assets are not present at a predictable relative path (an earlier `../../js/...` reference
404'd and left `App` undefined).

## Labels come from the form's own catalog

The same generation that renders the page emits its translation catalog
(`i18n/en-US/<form>.form.json`, keyed by the form's `<tprefix>`), and the page consumes it: the
title, the field labels, the button captions and the status steps all bind through
`T('<project>:<tprefix>.t.<id>', '<English literal>')`, and the two submit outcome messages
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.

Two things a change here must preserve:

- **The page is standalone, so it bootstraps its own catalog namespace.** `i18n.js` reads
`App.config.projectName`, which the SPA shell would normally provide; the form (like the
standalone report page) sets it in a one-line inline script before loading the translator.
Without it only the platform `application-core` chrome catalog is fetched and every
module-authored label silently stays English — which is exactly the bug
([#6692](https://github.com/eclipse-dirigible/dirigible/issues/6692)) this replaced.
- **A status step's `label` stays untranslated; its `title` is the translated one.** The active
step is found by matching the record's status value against `label`, so translating that
would leave every step inactive.

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 (`$SQ`/`$ESCSQ` in `index.html.template`).

## Follow-ups
- Feed-driven widgets (combobox/select/radio with `feeds`), documents, table, stepIndicator.
- Optionally have `FormIntentGenerator` emit neutral `ctx` handlers directly (the compat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,18 +174,21 @@ document.addEventListener('alpine:init', () => {
console.warn('[form] no handler for callback', callback);
},

// Generic submit for forms declaring a metadata URL and no explicit handler.
// Generic submit for forms declaring a metadata URL and no explicit handler. The two outcome
// messages come from this form's own catalog (the `dialogs` section the translate action emits,
// where successMsg carries the form's authored success text), falling back to the literals.
async submit() {
const url = '${metadata.url}';
if (!url) return;
this.state = 'submitting';
try {
await harmoniaHttp.post(url, this.model);
this.state = 'success';
this.message = 'Submitted.';
this.message = window.T ? T('$projectName:${tprefix}.dialogs.successMsg', 'Submitted.') : 'Submitted.';
} catch (e) {
const failed = window.T ? T('$projectName:${tprefix}.dialogs.errorTitle', 'Submit failed.') : 'Submit failed.';
this.state = 'error';
this.message = (e && e.message) || 'Submit failed.';
this.message = (e && e.message) || failed;
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## An apostrophe in an authored label would close the JS string literal a T() call sits in and break
## the whole Alpine expression, so every interpolated label goes through .replace($SQ, $ESCSQ) - the
## translation KEY included, since it is derived from the label and only spaces and _ . : are stripped
## out of it. Built from a single-quoted (non-interpolated) backslash so no literal escaping is
## assumed of VTL.
#set($SQ = "'")
#set($BS = '\')
#set($ESCSQ = "${BS}${SQ}")
## Every author-facing label resolves through this form's own catalog (i18n/<locale>/<form>.form.json,
## key <tprefix>.t.<id>), falling back to the English literal baked in here. The id was assigned to
## the model by the catalog-emitting generation pass, so nothing is derived at runtime - and a label
## the pass did not key (a hand-authored form control without a label) keeps the element's own text.
#macro(tlabel $el)#if($el.translation) x-text="T('${projectName}:${tprefix}.t.${el.translation.replace($SQ, $ESCSQ)}', '$!el.label.replace($SQ, $ESCSQ)')"#end#end
#macro(leaf $el)
#if($metadata.taskForm && $el.readonly && $el.model)
<!-- BPM task form: a read-only field renders as a detail cell - a muted label above its value,
Expand All @@ -8,7 +21,7 @@
`customer.name` shows the customer's name here rather than the raw FK id. A number is
formatted with its pattern (grouped, fixed decimals); other values render as-is. -->
<div class="vbox gap-0">
<span x-h-text.muted class="text-xs">$!el.label</span>
<span x-h-text.muted class="text-xs"#tlabel($el)>$!el.label</span>
#if($el.controlId == "input-number")
<span x-h-text class="text-sm break-words" x-text="fmtNumber(model.$el.model, '$!el.pattern')"></span>
#elseif($el.controlId == "input-date" || $el.controlId == "input-datetime-local" || $el.controlId == "input-time" || $el.controlId == "input-month")
Expand All @@ -18,34 +31,34 @@
#end
</div>
#elseif($el.controlId == "header")
<h1 x-h-text.h3 class="mb-2 col-span-full text-2xl font-semibold tracking-tight">$!el.label</h1>
<h1 x-h-text.h3 class="mb-2 col-span-full text-2xl font-semibold tracking-tight"#tlabel($el)>$!el.label</h1>
#elseif($el.controlId == "paragraph")
<p x-h-text class="col-span-full">$!el.label</p>
<p x-h-text class="col-span-full"#tlabel($el)>$!el.label</p>
#elseif($el.controlId == "line")
<hr x-h-separator class="col-span-full" />
#elseif($el.controlId == "spacer")
<div class="flex-1"></div>
#elseif($el.controlId == "link")
<a x-h-button data-variant="link" href="$!el.url">$!el.label</a>
<a x-h-button data-variant="link" href="$!el.url"#tlabel($el)>$!el.label</a>
#elseif($el.controlId == "input-textarea")
<div x-h-field class="col-span-full">
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<textarea x-h-textarea id="$el.id" rows="4" x-model="model.$el.model"#if($el.readonly) readonly#end></textarea>
</div>
#elseif($el.controlId == "input-number")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<!-- step="any": without it the browser default step=1 rejects fractional input on number fields. -->
<div x-h-input-number><input type="number" step="any" id="$el.id" x-model.number="model.$el.model"#if($el.readonly) readonly#end /></div>
</div>
#elseif($el.controlId == "input-checkbox")
<div x-h-field data-orientation="horizontal">
<span x-h-checkbox><input type="checkbox" id="$el.id" x-model="model.$el.model"#if($el.readonly) disabled#end /></span>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
</div>
#elseif($el.controlId == "input-date")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<div x-h-date-picker>
<input type="text" id="$el.id"#if($el.readonly) disabled#end />
<button x-h-date-picker-trigger aria-label="Choose date"></button>
Expand All @@ -54,7 +67,7 @@
</div>
#elseif($el.controlId == "input-datetime-local")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<div x-h-datetime-picker>
<input type="text" id="$el.id"#if($el.readonly) disabled#end />
<button x-h-datetime-picker-trigger aria-label="Choose date and time"></button>
Expand All @@ -63,15 +76,15 @@
</div>
#elseif($el.controlId == "input-time")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<div x-h-time-picker>
<input type="text" x-h-time-picker-input id="$el.id" x-model="model.$el.model"#if($el.readonly) disabled#end />
<div x-h-time-picker-popup></div>
</div>
</div>
#elseif($el.controlId == "input-month")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<div x-h-month-picker>
<input type="text" id="$el.id"#if($el.readonly) disabled#end />
<button x-h-month-picker-trigger aria-label="Choose month"></button>
Expand All @@ -80,7 +93,7 @@
</div>
#elseif($el.controlId == "input-week")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<div x-h-week-picker>
<input type="text" id="$el.id"#if($el.readonly) disabled#end />
<button x-h-week-picker-trigger aria-label="Choose week"></button>
Expand All @@ -89,15 +102,15 @@
</div>
#elseif($el.controlId == "input-color")
<div x-h-field>
<label x-h-label for="$el.id">$!el.label</label>
<label x-h-label for="$el.id"#tlabel($el)>$!el.label</label>
<input x-h-input type="color" id="$el.id" x-model="model.$el.model"#if($el.readonly) readonly#end />
</div>
#elseif($el.controlId == "button")
<button x-h-button#if($el.type == "emphasized") data-variant="primary"#elseif($el.type) data-variant="$el.type"#end type="button"#if($el.callback) @click="run('$el.callback')"#elseif($el.isSubmit) @click="submit()"#end>$!el.label</button>
<button x-h-button#if($el.type == "emphasized") data-variant="primary"#elseif($el.type) data-variant="$el.type"#end type="button"#if($el.callback) @click="run('$el.callback')"#elseif($el.isSubmit) @click="submit()"#end#tlabel($el)>$!el.label</button>
#else
<!-- TODO (forms v2): widget '$!el.controlId' not yet rendered in Harmonia -->
<div x-h-field>
<label x-h-label for="$!el.id">$!el.label</label>
<label x-h-label for="$!el.id"#tlabel($el)>$!el.label</label>
<input x-h-input type="text" id="$!el.id" x-model="model.$!el.model"#if($el.readonly) readonly#end />
</div>
#end
Expand Down Expand Up @@ -152,14 +165,16 @@
<hr class="col-span-full mb-4" style="border: none; border-top: 1px solid var(--border);" />
<!-- Document status flow, extracted from the intent (the bound entity's function: EntityStatus
relation -> the status entity's non-terminal seeds, in order). Read-only (non-interactive);
active step = the current status name (model.$metadata.statusVar). -->
<nav x-h-step-indicator="([#foreach($s in $metadata.steps){ label: '$s.label' }#if($foreach.hasNext), #end#end]).findIndex(s => s.label === model.${metadata.statusVar}) + 1" data-orientation="horizontal" class="col-span-full mb-4">
<template x-for="(step, i) in [#foreach($s in $metadata.steps){ label: '$s.label', description: '$!s.description' }#if($foreach.hasNext), #end#end]" :key="step.label">
active step = the current status name (model.$metadata.statusVar).
`label` stays the untranslated seed name because it is what the record's status value is
MATCHED against; `title` is the same name resolved through the catalog and is what shows. -->
<nav x-h-step-indicator="([#foreach($s in $metadata.steps){ label: '$s.label.replace($SQ, $ESCSQ)' }#if($foreach.hasNext), #end#end]).findIndex(s => s.label === model.${metadata.statusVar}) + 1" data-orientation="horizontal" class="col-span-full mb-4">
<template x-for="(step, i) in [#foreach($s in $metadata.steps){ label: '$s.label.replace($SQ, $ESCSQ)', title: #if($s.translation)T('${projectName}:${tprefix}.t.${s.translation.replace($SQ, $ESCSQ)}', '$s.label.replace($SQ, $ESCSQ)')#{else}'$s.label.replace($SQ, $ESCSQ)'#end, description: #if($s.description)'$s.description.replace($SQ, $ESCSQ)'#{else}''#end }#if($foreach.hasNext), #end#end]" :key="step.label">
<div x-h-step-indicator-item="i + 1">
<button x-h-step-indicator-trigger data-non-interactive="true" type="button">
<span x-h-step-indicator-marker x-text="i + 1"></span>
<span x-h-step-indicator-content>
<span x-h-step-indicator-title x-text="step.label"></span>
<span x-h-step-indicator-title x-text="step.title"></span>
<span x-h-step-indicator-description x-show="step.description" x-text="step.description"></span>
</span>
</button>
Expand Down Expand Up @@ -239,6 +254,12 @@
<!-- The single shared value formatter (dates/numbers). Self-contained + dependency-free, so it loads
by absolute URL even in the standalone task-form iframe where the shell assets are absent. -->
<script src="/services/web/application-core/shell/js/services/format.js"></script>
<!-- i18n: the shared shell translator (window.T + the Alpine i18n store), which serves the labels,
the status steps and the submit messages from this form's own catalog. This page is standalone
(no SPA shell / app.js), so the catalog namespace is bootstrapped inline: i18n.js reads
App.config.projectName at load to fetch the project's catalogs next to 'application-core'. -->
<script>window.App = window.App || { config: {} }; App.config = App.config || {}; App.config.projectName = '$projectName';</script>
<script src="/services/web/application-core/shell/js/services/i18n.js"></script>
<script src="./form.js"></script>
<script defer src="/webjars/alpinejs/dist/cdn.min.js"></script>
<script src="/webjars/codbex__harmonia/dist/harmonia.min.js"></script>
Expand Down
Loading
Loading