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
2 changes: 2 additions & 0 deletions .claude/docs/intent-dsl-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Loaded on demand, not on every session: read this before changing a DSL construc

**Show it only from a status on (`visibleWhen:`, [#7502](https://github.com/eclipse-dirigible/dirigible/issues/7502)):** composition-child panels rendered unconditionally, so a DRAFT invoice showed empty payment-allocation, copy and reminder panels - `forbidWhen` and `locksWithMaster` hide only the Add/edit/delete affordances and `visibleTo:` gates by role, not status. `visibleWhen: "<condition>"` on a field, or on a composition child (written in the master's terms, `Status != DRAFT`), leaves the input or the whole panel out of the generated views until the condition holds against the record the page shows. Same grammar as `requiredWhen` (`==`/`!=`, a list = AND, statuses by seed name); UI-only - nothing is hidden from the API and nothing is deleted when a record regresses.

**A process decision can branch on a to-one relation of its trigger entity ([#7648](https://github.com/eclipse-dirigible/dirigible/issues/7648)):** the field loader inserted before a gateway published the entity's own FIELDS only, so `if: "SendMethod == 1"` - branching on a nomenclature the row carries as a foreign key, which is how "e-mail it or print it" is modelled - got no loader at all. The identifier resolved only when a task form happened to post it as a variable; a completion through the Inbox API carries none, and Flowable then failed the expression on the unknown property and the instance stopped at the gateway. The foreign key is a number on the row like any other column, so all three halves are the ones the field path already walks: the loader looks for to-one relation names too, the gateway's condition is rewritten to the PascalCase the loader publishes, and a seeded NAME on the right-hand side is resolved against the nomenclature THAT relation points at - never the record's own status, which is a different nomenclature carrying a different id for the same word.

**A picker can say a target row is not pickable (`pickable:`, [#7496](https://github.com/eclipse-dirigible/dirigible/issues/7496)):** a to-one's dropdown rendered every target row identically, so a clerk picked a customer with no registration number onto an invoice, typed twenty lines, and learned only at Issue that the invoice could not go out - `dependsOn.filterBy`/`where:` narrow by one value, not by a completeness rule. `pickable: { when: [registrationNumber != null, address != null], else: mark|hide, message: ... }` reads the check guard grammar over the TARGET's own properties, plus `!= null` / `== null` presence tests on any field type; `mark` (the default) lists a failing row disabled with the message as a second line (Harmonia's `aria-disabled` + `data-description`), `hide` leaves it out, and a value the record already holds always keeps its label. Emitted as one JSON attribute `widgetPickable`, evaluated by the shared runtime (`basePage.pickableOptions` / `visibleOptions`) on every picker that builds its own options - manage form, document header and line dialog, my/partner forms and documents. **It never gates the server** (a REST client bypasses any picker): the write-side rule stays a `checks:` entry. Details in the engine-intent guide's pickable bullet.

**Two values of one row, related (`checks: compare`, [#7095](https://github.com/eclipse-dirigible/dirigible/issues/7095)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - nothing compared two fields of the SAME record, so "a due date is never before the invoice date" was not expressible and a document was saved (200), issued and overdue the moment it existed; the module's workaround was a `calculatedActionOnCreate`/`OnUpdate` class per document type that silently CORRECTED the date instead of refusing it, which is a different thing and never tells the clerk. `- { kind: compare, field: due, op: ge, than: date, message: ... }` is row-level by default, like `exactlyOne`: enforced in every generated controller's `validate()` (the entity, personal and partner surfaces) as a 400 carrying the authored message - a rule about two values of one row holds from the first save, not from a transition (the optional gate that routes it to the transition instead arrived with [#7338](https://github.com/eclipse-dirigible/dirigible/issues/7338), below). `op:` is `ge`/`gt`/`le`/`lt`/`eq`/`ne`, spelled out because an omitted operator has no defensible default. Both operands are the entity's own **fields** - a comparison of two foreign keys means nothing - and must sit in ONE comparison family, which is what the generated code needs: two temporals compare through their own `compareTo` (a `LocalDate` does not compare to an `Instant`), two numbers by value through `BigDecimal` so a `decimal` against a `long` stays exact. Only dates, timestamps and numbers compare; a string / `month` / `week` is refused rather than silently ordered lexicographically, as is a field-with-itself. An **absent operand is not a violation** - a comparison is about two values that exist, and requiredness is its own declaration.
Expand Down
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` /
- **Context = id only.** The trigger calls `Process.start("<proc>", key, "{\"Id\": <id>}")` — no entity snapshot — then seeds *locators* via `Process.setVariable`: `__entityUrl` + `__entityId` (the entity's own REST controller + id) and, per to-one relation, `__<Fk>EntityUrl` + `__<Fk>EntityLabel`. So the context can never go stale (it holds no entity data).
- **The form fetches live.** On open, `template-form-builder-harmonia`'s `form.js` loads the task variables, then GETs `__entityUrl/__entityId` and overlays the live fields — so a read-only field (e.g. a document `Total`) is always current. **This is the literal "form fetches the entity".** It is NOT a path-agnostic-rule violation: the *intent* generators still emit no controller paths — the **events template** (`Trigger.java.template`, which already has `project`/`javaGenFolderName`/`javaPerspective`/`entity`) assembles the URL and hands it to the form as a process variable. The standalone form learns the URL from the context, not from shell knowledge.
- **FK → name.** For each to-one relation, `GlueIntentGenerator.buildRelationLinks` emits the logical target names (project / model / perspective / entity + label field — via `CrossModelSupport` for cross-model, the entity's own `name` field for same-model); the generation pipeline (triggers case) builds the controller URL (cross-model uses the target project + sanitized model alias as gen folder, same as the dropdowns); the trigger seeds them; `form.js.resolveRelationNames()` fetches each FK's record and replaces the raw id with its label, **falling back to the id** when a URL/record is missing.
- **Decisions keep the resolver pattern, loading by id.** Since the snapshot is gone, the `Resolver` `JavaDelegate` now loads the **owner (trigger entity) by its id** to read the FK, then loads the target and publishes the field (`ProcessResolverSupport` carries the owner entity/perspective/key). A decision on the trigger entity's **own** field (e.g. `amount > 10000`) is handled symmetrically by a **field loader** (`ProcessFieldLoadSupport` → `fieldLoaders` glue → `FieldLoader.java.template`): a `JavaDelegate` inserted before the gateway that loads the owner by id and publishes just the bare own-field identifiers the condition references (a `relation.field` token is left to the resolver, the form-set `action` is ignored). So every decision input is loaded at the latest moment from the id-only context.
- **Decisions keep the resolver pattern, loading by id.** Since the snapshot is gone, the `Resolver` `JavaDelegate` now loads the **owner (trigger entity) by its id** to read the FK, then loads the target and publishes the field (`ProcessResolverSupport` carries the owner entity/perspective/key). A decision on the trigger entity's **own** field (e.g. `amount > 10000`) is handled symmetrically by a **field loader** (`ProcessFieldLoadSupport` → `fieldLoaders` glue → `FieldLoader.java.template`): a `JavaDelegate` inserted before the gateway that loads the owner by id and publishes just the bare own-field identifiers the condition references (a `relation.field` token is left to the resolver, the form-set `action` is ignored). **The bare identifiers include the entity's TO-ONE RELATIONS (#7648).** The loader looked for fields only, so `if: "SendMethod == 1"` - branching on a nomenclature the row holds as a foreign key, which is how "e-mail it or print it" is modelled - got no loader at all: the identifier resolved only when a task form happened to post it as a variable, and a completion through the Inbox API carries none, so Flowable failed the expression on the unknown property and the instance stopped at the gateway. The FK is a number on the row like any other column, so the field path needed nothing new, only to look: `ownNames` adds the to-one relation names, `BpmnIntentGenerator.ownFieldPascalCase` rewrites them to the PascalCase the loader publishes, and `StatusSymbolResolver` resolves a seeded NAME on the right-hand side against the nomenclature THAT relation points at - never the record's own status, a different nomenclature carrying a different id for the same word. Covered by `ProcessDecisionRelationTest`. So every decision input is loaded at the latest moment from the id-only context.
- **No hydrator.** The earlier `HydrateSupport`/`Hydrate.java.template`/`hydrators` glue was removed — the live fetch supersedes it (it captured data too early for documents).
- **Read-only render + per-field editable opt-in.** A form referenced by a `userTask` is a **task form**: `FormIntentGenerator` marks every control `readonly` by default and emits `metadata.taskForm=true` (+ the bound entity name + the editable set); the Harmonia form-builder (`template-form-builder-harmonia/ui/index.html.template`) renders a read-only control as a **Label: Value** row (like the detail card) instead of an input. A control whose model is a resolver variable (list `customer.name`) shows the resolved name, not the FK id. Opt a field back to editable with `editable: [Field]` on the form (`FormIntent.editable`); the parser validates each is a displayed field of `forEntity` (relation.field can never be editable — editing it would not write back). Non-task forms (e.g. an inbound create form) keep editable controls.
- **An `editable` entry may name a to-one RELATION, and then it renders as a record picker.** The gap it closes: `editable` took plain fields only and `setRelationField` takes a literal seed id baked in at authoring time, so a flow whose fallback is "a person chooses the related record" (pick the driver, pick the account, pick the approver) had to be built OUTSIDE the process as an ordinary entity form plus a guarded `transitions:` button — two user actions where the model describes one. **The Writer needed no change**: an FK column holds the target's integer key, which is the `integer`/`long` coercion it has always emitted, so the only missing server-side piece was that `WriterSupport.editableFields` looked at `fieldOf(owner, name)` and never at the relations. The UI half is the real work — `FormIntentGenerator` emits an `input-select` and `template-form-builder-harmonia` renders it through the Harmonia `x-h-select` contract (v1 fell back to a text input for every unknown widget). **The option list is located by process variables, never by a path**: the control carries the NAMES `__<Fk>EntityUrl` / `__<Fk>EntityLabel` — the locators `Trigger.java.template` already seeds for every to-one relation of its trigger entity — plus the target's own key property, and `form.js` reads the URL out of its own model, so the path-agnostic rule holds unchanged. That locator is also what bounds the feature: the picker only works on a task form whose `forEntity` IS the trigger entity, which is the same entity the Writer writes back to, so one parser check covers both. **Two runtime interactions are load-bearing.** (1) `resolveRelationNames()` replaces an FK id in the model with the related record's NAME for the read-only Label: Value cell; a picked relation must be excluded or the select would match no option and the completion would submit a name into an integer column — so `__harmoniaRelationSelects` lists **editable** selects only, and a read-only one keeps going through the name resolution. (2) An `x-h-select` option's `data-value` is an HTML attribute and therefore a string, so the model value is stringified on load (the same rule the generated entity forms follow) — the Writer's `Integer.valueOf(...toString().trim())` takes it from there. The picker offers the first 1000 records and logs when it truncates; it is not a search box. Rejected shapes, each because the picker would be wrong rather than merely unsupported: the `function: EntityStatus` relation (the lifecycle graph owns status moves), the composition parent (re-pointing it re-files the record mid-flow), a cross-model relation (its key belongs to the owner model) and a non-task form (no process context to load from). Covered by `EditableRelationIntentTest`, `TaskFormRelationPickerTest` and `IntentEngineIT.an_editable_relation_renders_a_record_picker_and_writes_the_chosen_foreign_key`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.FieldIntent;
import org.eclipse.dirigible.components.intent.model.RelationIntent;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.model.ProcessIntent;
import org.eclipse.dirigible.components.intent.model.StepIntent;
Expand Down Expand Up @@ -70,7 +71,7 @@ public static List<FieldLoad> fieldLoads(IntentModel model) {
if (owner == null) {
continue; // no trigger entity -> no own-field context to load
}
Set<String> ownFieldNames = ownFieldNames(owner);
Set<String> ownFieldNames = ownNames(owner);
for (StepIntent step : process.getSteps()) {
if (!"decision".equals(step.getKind()) || step.getName() == null) {
continue;
Expand Down Expand Up @@ -112,14 +113,37 @@ private static List<String> referencedOwnFields(String condition, Set<String> ow
return new ArrayList<>(fields);
}

private static Set<String> ownFieldNames(EntityIntent owner) {
/**
* The identifiers of the trigger entity a decision may branch on: its own fields, and - since issue
* #7648 - its TO-ONE relations, whose foreign key the row carries exactly as it carries a field.
*
* <p>
* A relation was not loadable before, so {@code if: "SentMethod == 1"} got no loader at all: the
* identifier resolved only if a task form happened to post it as a variable, and a completion
* through the Inbox API carries none - Flowable then failed the expression on the unknown property.
* The FK is a number on the entity like any other column, so loading it needs nothing the field
* path does not already do; what it needed was to be looked for.
*
* @param owner the trigger entity
* @return the authored names a decision may name, fields and to-one relations alike
*/
private static Set<String> ownNames(EntityIntent owner) {
Set<String> names = new LinkedHashSet<>();
for (FieldIntent field : owner.getFields()) {
if (field.getName() != null && !field.getName()
.isBlank()) {
names.add(field.getName());
}
}
if (owner.getRelations() != null) {
for (RelationIntent relation : owner.getRelations()) {
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
if (toOne && relation.getName() != null && !relation.getName()
.isBlank()) {
names.add(relation.getName());
}
}
}
return names;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,16 @@ private static Map<String, String> ownFieldPascalCase(ProcessIntent process, Map
rewrites.put(field.getName(), IntentNaming.pascalCase(field.getName()));
}
}
// ...and its to-one relations (#7648): the loader publishes their foreign key under the same
// PascalCase name a field gets, so the gateway's condition has to be rewritten to match.
if (entity.getRelations() != null) {
for (RelationIntent relation : entity.getRelations()) {
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
if (toOne && relation.getName() != null) {
rewrites.put(relation.getName(), IntentNaming.pascalCase(relation.getName()));
}
}
}
}
return rewrites;
}
Expand Down
Loading
Loading