diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index f82e4ddae8..efcebdaad9 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -403,6 +403,7 @@ Semantics worth knowing: - **`hierarchy:` on an entity + `leafOnly:` on a to-one relation = tree entities (chart-of-accounts shape).** `hierarchy: Parent` names the entity's own optional to-one self-relation as the tree edge (parser `validateHierarchy`: self-target, non-composition, optional — a required parent leaves no way to author a root). Emitted as the entity-level `hierarchyProperty` (PascalCase FK property) on the `.edm`/`.model`; `CrossModelSupport.TargetInfo` gained a `hierarchyProperty` component so a cross-model referencing module can read it off the owner's `.model`. `leafOnly: true` on a relation targeting a hierarchical entity emits `widgetLeafOnly` + `widgetHierarchyProperty` (the TARGET's edge property) on the FK; parse-checked same-model, generation-checked (loud) for a resolved cross-model target. Enforcement is dual-layer like `where:`: the generated REST controller gains `validateReferences` (leafOnly = child-count via the target's repository — `ModelParameterProcessor.leafOnlyRepositoryClass`, a cross-model Java import that resolves because client-Java compiles registry-wide; plus a walk-up cycle guard for the entity's own edge, depth-capped at 100), while the Harmonia pickers mirror it (`hierarchizeOptions`: depth-first order, em-space indentation, leaves only — form page, document header, and the item dialog via `detail-register`'s `#hierMeta` → `col.hier` + the `filteredOptions` store). The manage LIST renders as a Harmonia `x-h-tree` when the entity declares a hierarchy — fixed-depth markup recursion (6 levels; Alpine has no recursive template), search/column-filters fall back to the flat table so matches in collapsed branches stay findable. Plain (non-leafOnly) pickers to hierarchical targets stay flat — indentation rides the leafOnly metadata only (documented v1 scope). - **`order:` on an entity = explicit UI control order.** A list of property names (fields + to-one relations interleaved, matched case-insensitively against the authored names) that sequences the generated form inputs / list columns / detail rows. Default (no `order`) is fields-in-declaration-order then to-one relations, which pushes every relation last — bad UX for a line-item form where `Product`/`UoM` want to sit next to `Name`/`Quantity`. `EdmIntentGenerator.applyOrder` reorders the built `properties` list (which the templates AND the mxGraph diagram both consume) before emitting; a **partial** order is honoured (unlisted properties keep their relative position, appended after the listed ones), and system properties (`ProcessId`/audit columns) are simply left unlisted. The parser (`validateOrders`) checks every listed name resolves to a declared field/relation and rejects duplicates. Worked example: `sample-intent-multi-model` `SalesInvoiceItem` (`order: [Id, SalesInvoice, Product, Name, Quantity, UoM, Price, Discount, Net, Vat, Total]`). - **The notify fan-out addresses TWO records, and says which one it means (`attach: recordPrint` + the `record.` scope, #6717).** `notify.forEach` sends one message per row of a related entity, and until now everything in the block resolved against the ROW - right for the payslip case (one document per row), impossible for its mirror: **one** document to **many** recipients, where the rows are only the recipient list (a request for quotation mailed to each invited supplier, an agenda to each participant) and the document belongs to the record they hang off. **`attach: recordPrint`** renders the fan-out's **anchor record** instead of the row - fan-out-only (without one, `attach: print` already renders that record), the ANCHOR is what must be a printable document, and `language:`/`languageFrom:` then read off it, because the render happens **ONCE**: `NotifySupport.printAttachment` pre-renders every expression against the `source` local (`RECORD_LOCAL`) instead of the per-row `entity`, and both fan-out templates (`Send`, `Transition`) call a `renderDocument(source)` before the loop - and not at all when there are no rows, so an empty recipient list costs no render and cannot fail a step for nobody. **Placeholders are explicitly scoped:** a bare path keeps resolving against the ROW and the reserved prefix **`record.`** reaches the anchor (`{record.number}`, ONE field of it - a walk on would need a second load per message and belongs in a field of the record). The recipient may never be record-scoped (the rows ARE the recipients, so it would mail one address once per row), and `record.` outside a fan-out is an error (there the bare placeholder already IS the record's) - implicit mixing is how a message quotes the wrong party with nothing in the output to show it. The generated per-row send method takes the anchor only when a message actually quotes it (the `notifyRecordScoped` glue key). **Alongside it, a `forEach` at a call site that cannot generate one is now REJECTED** - a `schedules[].notify` already runs per matched row and a `notifications[]` entry is about the event record, so both parsed it and silently sent the single per-record message instead (the authored-but-unconsumed class). Covered by `GlueSendDocumentTest` (scoping + the six rejections) and `IntentEmissionCoverageIT` (the `BillFlow` `shareBill` step: one render call, `renderDocument(source)` fed with the anchor's key, the row-scoped recipient, the record-scoped subject). +- **A notify body carries LINKS, and the intent never spells a route (`{recordUrl}` / `{inboxUrl}`, #6553).** `{appUrl}` (#6642) supplies only the origin, so "you have an approval waiting" still had to hand-type `{appUrl}/services/web//gen//index.html#/Order/{id}/edit` into the body - the generated app's URL layout, typed into the one artifact that is forbidden to know it, and silently stale the day a template changes it. **`{recordUrl}`** is the deep link to the record the message is about and **`{inboxUrl}`** the link to the recipient's process Inbox; both are reserved tokens resolving to a **bare Java identifier** - `NotificationSupport.Resolver` emits the name and records the use, `NotifySupport.deepLinkFields` carries the only facts the intent owns (`recordUrlEntity` / `recordUrlKeyProperty` - model facts, not paths) plus a `uses*` flag per link, and the **events template DECLARES the local**, composing `/services/web//gen//index.html#///edit` from the parameters it already has. That split is the whole design: it is the template layer that knows the routes (the same reason `Trigger.java.template`, not the generator, assembles `__entityUrl`), so the path-agnostic rule holds and a route change stays a template change. A link is declared only where the message names it (no dead local), and one **fan-out** links the ROW like every other bare path - `{record.}` reaches the anchor for VALUES, but there is deliberately no anchor LINK. All four notify call sites got it from the two shared seams (`buildNotifications`, `buildSchedules`, `notifyFields` -> transitions + sends), so no call site can be forgotten. Covered by `NotificationSupportTest`, `IntentEngineIT` (both links, and the unused one NOT declared) and `IntentEmissionCoverageIT` - where it is also the compile proof: an undeclared local fails the whole client-Java batch and every REST assertion in that gate. - **`transitions:` (top-level) = guarded on-demand status flip (void / cancel / close / reopen).** The missing affordance for a document whose create-time process has ENDED: process triggers fire only on create/update/delete, and `actions:` only opens a custom page - nothing declarative could transition a finished document again. `TransitionIntent` + parser `validateTransitions` (forEntity must declare a `function: EntityStatus` relation; `from:` = non-empty list of allowed source seed ids; `setStatus:` = target seed id not in `from`; optional `when: " ==|!= "` guard over an own field, resolved case-insensitively - the identifier follows the Calc PascalCase convention). Two halves, the `generates` pattern: `TransitionsIntentGenerator` (`@Order(470)`) contributes the per-record button (`-transition-action.extension`/`.js` on `-custom-action`, descriptor carries `endpoint`); `GlueIntentGenerator.buildTransitions` pre-renders EVERYTHING (the `allowedExpr` over an `int currentStatus` local, the `when` guard as a full `Calc.eval(...).compareTo(...)` expression - null field reads as 0) into the `transitions` glue collection -> the pipeline's collection case -> `Transition.java.template`: a `@Controller` at `gen/events//Transition/run` that re-loads the record, returns **409** (via `sdk.http.Response.setStatus`) with the reason when a guard fails, flips ONLY the status column via the targeted `updateProperty` (no `-updated` re-fire - no onUpdate reactions), re-loads, and publishes `-transitioned` - the SAME channel the workflow setters and `generates.sourceStatus` publish, so `postings:` glue observes a manual void exactly like a workflow transition. This realizes the "guarded transition" half of the Tier-2 `lifecycle:` sketch below for the post-process case. Covered by `TransitionsIntentTest` + `GlueTransitionsTest` + the `IntentEmissionCoverageIT` transitions assertions. - **`generates` + `event:` = the create-from runs itself (#6711).** A create-from was strictly a **user action** - a button on the source view - so "when the source reaches this state, mint the follow-up document" had no expression: a `generates` button plus a process `wait` degraded the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven but emits **flat mapped rows** and cannot reference the freshly created header, and the remaining option was a hand-written `delegate`. A `generates` entry now accepts `event: { onTransition: , when: " == " }` (guard mandatory, status by seeded NAME or id) or `{ onCreate: }` (guard optional - a source with no lifecycle), mirroring `postings`' event axis. **The event says WHEN, never what**: the entity it names must be the one `from:` declares and `model:` is rejected (`fromUses:` owns that), both parser-checked - two ways to name the source could only drift. **At-most-once is derived, not declared twice**: the `map` entry copying the source's PK IS the back-reference, so `GlueIntentGenerator.putGeneratesEvent` derives `backRefProperty` from it and fails loudly when it is missing (the parser catches the local case earlier with the fix in the message; the cross-model source's key field is only known once the owner `.model` resolves). Emission: the existing `Generate.java.template` was refactored so its body is a `create(Integer sourceId)` method carrying the guard (`findAll(eq(backRef, sourceId))` -> return the existing document), and a new **`GenerateOnEvent.java.template`** renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-loads the source, applies the status guard and calls `new Generate().create(id)` - **it carries no mapping of its own**, which is what keeps the two triggers from diverging. The listener is a collection of its own (`generateEvents`, the filtered `generates` list - one file per entry is the collection contract, and a create-from with no event must contribute no listener) but shares `bindGenerate`, so both templates see the same descriptor. `button:` decides the click half: default **true** without an event and **false** with one (declaring an event is how an author says nobody has to click), `button: true` keeps both (they share the one guard), `button: false` with no event is rejected - the action would have no trigger at all. Without a button the class gets no `@Controller`/`@Post` and no custom-action descriptor or i18n label - no endpoint nothing links to. **The template gates the controller half on the NEGATIVE (`#if(!$eventOnly)`)** so a `.glue` written before this key existed keeps rendering the endpoint it always did. `sourceStatus:` composes (the flip cannot re-trigger the create-from - the guard has already claimed the source). Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference) + `IntentEmissionCoverageIT` at both layers: posting a Slip mints the Voucher **with its computed line** while nobody calls the create-from, and a click afterwards returns that same voucher. - **`prompt:` on a `generates` action = a declared input form before the create (#6685).** The gap it closes: `transitions:` writes but takes no input and `generates:` creates but declares every value up front, so an action that collects the two answers the source cannot derive (which payment, how much) had to be a hand-written page. It reaches a post-issue child on an IMMUTABLE document too, because per-record action buttons are deliberately NOT gated on mutability (that is why Void works) - the **action-shaped sibling of `locksWithMaster: false`** (#6700), which reopens the child's own panel: the panel is the affordance for ordinary data entry, a prompted action for a guided create over mostly-derived values. `prompt:` entries name fields / to-one relations of the TARGET; parser (`validateGeneratesPrompt`): local target only, target must declare a composition to-one relation to `forEntity` (that guarantees the generated detail registration the dialog renders from), scope `entity`, no `timestamp` fields, no overlap with `map`/`defaults` (one writer), no duplicates, and **no `event:`** (an event-driven create-from runs with nobody there to answer the form - which is also why the prompted values ride the ENDPOINT path only: `run()` checks the required ones and passes the map into `create(sourceId, values)`, while the event listener's `create(sourceId)` signature is untouched). Server half: `promptFields` in the glue (PascalCase prop + required + a pre-rendered `Object raw` -> field-type conversion), `Generate.java.template` takes `values` in the Request, 400s on a missing required input BEFORE anything is written, and sets prompted values after map/defaults - the save still goes through the target's repository so numbering/checks/events fire. Client half: the descriptor carries `prompt` + `promptEntity` (authored names ONLY - control types, lookup URLs and `dependsOn` metadata are resolved AT RUNTIME from `App.detailsFor(view)`'s edit-columns registration, so the intent layer never references template routes); the shared `customActions` store opens an input dialog instead of the plain confirm (`openPrompt`/`promptRun` + a mini dependsOn cascade seeded from the clicked master id - the invoice's Customer chain narrows the payment list, `valueFrom` defaults the amount), degrading to the confirm when the registration is absent (the shared shell). Dialog markup rides in all five shells wrapped in the `customActionPrompt` Alpine component so the Velocity shell stays `$store`-free. Covered by the `GeneratesIntentTest` prompt tests + `GlueGeneratesTest.promptFieldsRenderTypedConversions` + the `IntentEmissionCoverageIT` prompted-generates assertions (emission + 400 + value-reaches-the-row). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 921b429266..bbe3009ca4 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -455,6 +455,7 @@ private static List> buildNotifications(IntentModel model, M entry.put("subjectExpression", plan.subjectExpression()); entry.put("bodyExpression", plan.bodyExpression()); entry.putAll(NotifySupport.attachmentFields(attachment)); + entry.putAll(NotifySupport.deepLinkFields(plan, byName.get(entity))); notifications.add(entry); } return notifications; @@ -1086,6 +1087,7 @@ private static Map notifyFields(NotificationIntent notify, Entit // named `entity` in the templates for exactly this reason, so one expression set serves both // shapes), the ANCHOR record's for `attach: recordPrint`. fields.put("attachKeyProperty", send && attachment != null ? IntentEntities.keyFieldName(document) : ""); + fields.putAll(NotifySupport.deepLinkFields(send ? plan : null, about)); return fields; } @@ -2849,9 +2851,11 @@ private static List> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map - * A value or {@code {placeholder}} is one of: the reserved {@code appUrl} config token (the - * application's external base URL, see {@link #APP_URL_TOKEN}), a direct field of the event + * A value or {@code {placeholder}} is one of: a reserved link token ({@code appUrl} - the + * application's external base URL, see {@link #APP_URL_TOKEN}; {@code recordUrl} / {@code inboxUrl} + * - the ready-made deep links, see {@link #RECORD_URL_TOKEN}), a direct field of the event * entity (rendered {@code entity.}), or a one-hop {@code relation.field} of a * to-one relation (rendered against a related entity the listener loads once by FK id - the same * one-hop mechanism the decision resolvers use, see {@link ProcessResolverSupport}). Multi-hop @@ -68,6 +69,30 @@ public final class NotificationSupport { private static final String APP_URL_EXPRESSION = "org.eclipse.dirigible.sdk.core.Configurations.get(\"" + DirigibleConfig.APP_BASE_URL.getKey() + "\", \"\")"; + /** + * The reserved {@code {recordUrl}} placeholder name - the ready-made deep link to the record the + * message is about ("you have an approval waiting" is useless without one), where + * {@link #APP_URL_TOKEN} supplies only the origin and leaves the author to type the route by hand. + *

+ * It resolves to a bare Java identifier, not to an expression: the local is declared by the + * events template, which is the layer that knows the generated application's routes. The intent + * layer contributes only model facts - the entity and its key property, carried in the glue as + * {@code recordUrlEntity} / {@code recordUrlKeyProperty} - so the path-agnostic rule holds (see the + * engine-intent guide) and a change to the generated app's URL layout is a template change alone. + *

+ * Inside a fan-out it links the row, like every other bare path: the row is what that + * message is about. The anchor record is reachable for VALUES through {@code {record.}}, but + * not as a link - a fan-out that wants to point at its anchor should say so with {@code {appUrl}}. + */ + static final String RECORD_URL_TOKEN = "recordUrl"; + + /** + * The reserved {@code {inboxUrl}} placeholder name - the deep link to the recipient's process + * Inbox, the other half of "a notification cannot carry a link to the record or task". Declared by + * the events template exactly like {@link #RECORD_URL_TOKEN}, and needing no model facts at all. + */ + static final String INBOX_URL_TOKEN = "inboxUrl"; + private NotificationSupport() {} /** @@ -102,9 +127,13 @@ public interface CrossModelLookup { public record CrossModelTarget(String perspectiveName, String project, String modelAlias, java.util.Set propertyNames) { } - /** The translated, ready-to-render shape of a notification. */ + /** + * The translated, ready-to-render shape of a notification. The two {@code uses*} flags report which + * template-declared deep-link locals the expressions reference, so a generated handler declares + * only the links its message actually uses. + */ public record Plan(List loads, String guardExpression, String toExpression, String subjectExpression, - String bodyExpression) { + String bodyExpression, boolean usesRecordUrl, boolean usesInboxUrl) { } /** @@ -225,7 +254,8 @@ public static Plan plan(String to, String subject, String body, String when, Ent } String subjectExpression = resolver.text(subject); String bodyExpression = resolver.text(body); - return new Plan(resolver.loads(), guard(when), recipient, subjectExpression, bodyExpression); + return new Plan(resolver.loads(), guard(when), recipient, subjectExpression, bodyExpression, resolver.usesRecordUrl(), + resolver.usesInboxUrl()); } /** @@ -279,6 +309,8 @@ private static final class Resolver { private final Set settingEntities; private final CrossModelLookup crossModel; private final Map loads = new LinkedHashMap<>(); + private boolean usesRecordUrl; + private boolean usesInboxUrl; Resolver(EntityIntent entity, EntityIntent anchor, Map byName, Map compositionParents, CrossModelLookup crossModel) { @@ -294,6 +326,14 @@ List loads() { return new ArrayList<>(loads.values()); } + boolean usesRecordUrl() { + return usesRecordUrl; + } + + boolean usesInboxUrl() { + return usesInboxUrl; + } + /** A single value (the {@code to} recipient): literal, direct field, or relation.field. */ String value(String raw) { if (raw == null || raw.isBlank()) { @@ -354,6 +394,17 @@ private String access(String path, boolean recordScope) { if (APP_URL_TOKEN.equals(path)) { return APP_URL_EXPRESSION; } + // The two deep links are locals the events template declares - the layer that knows the + // generated routes. Emitting the identifier here is what keeps the route out of the intent + // layer; the flags tell that template which of them to declare. + if (RECORD_URL_TOKEN.equals(path)) { + usesRecordUrl = true; + return RECORD_URL_TOKEN; + } + if (INBOX_URL_TOKEN.equals(path)) { + usesInboxUrl = true; + return INBOX_URL_TOKEN; + } if (recordScope && anchor != null && path.startsWith(NotifySupport.RECORD_SCOPE + ".")) { // The anchor record of a fan-out, already loaded by the generated code: one field of it, // never a walk on (that would need a second load per message, and the composed value diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotifySupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotifySupport.java index b1ffc49f5f..180349e27a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotifySupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotifySupport.java @@ -172,6 +172,32 @@ public static Map fanOutFields(FanOut fanOut) { return fields; } + /** + * The glue keys the reserved deep-link tokens contribute, always present so the templates can + * compare them. The two {@code uses*} flags say which link locals the events template must declare; + * the other two are the only facts the intent layer supplies towards the record link - the entity + * and its key property. The ROUTE itself is assembled by that template, which is the layer that + * knows the generated application's URL layout (the path-agnostic rule). + * + *

+ * In a fan-out {@code about} is the ROW, so {@code recordUrl} links the row - the thing that + * message is about - exactly like every other bare path. + * + * @param plan the translated notify block, or {@code null} when nothing is sent + * @param about the entity the message is about (a fan-out's row) + * @return the {@code usesRecordUrl} / {@code usesInboxUrl} / {@code recordUrlEntity} / + * {@code recordUrlKeyProperty} keys + */ + public static Map deepLinkFields(NotificationSupport.Plan plan, EntityIntent about) { + boolean record = plan != null && plan.usesRecordUrl() && about != null; + Map fields = new LinkedHashMap<>(); + fields.put("usesRecordUrl", String.valueOf(record)); + fields.put("usesInboxUrl", String.valueOf(plan != null && plan.usesInboxUrl())); + fields.put("recordUrlEntity", record ? about.getName() : ""); + fields.put("recordUrlKeyProperty", record ? IntentEntities.keyFieldName(about) : ""); + return fields; + } + /** * A resolved print attachment: everything the generated code needs to render and name the PDF. * diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 3c6cd7f1de..af05074731 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -116,12 +116,16 @@ not as an apology. `to` / `subject` / `body` (+ `channel: email`), with `{field}` / `{relation.field}` interpolation in the subject and body, plus the optional **`attach: print`** that mails the record's own rendered document - see *send a document by e-mail*. -- **`{appUrl}` is a reserved config token, not a field.** It resolves to the application's external - base URL (`DIRIGIBLE_APP_BASE_URL`, tenant-overridable), so a body can compose a deep link by hand, - e.g. `body: "Review it here: {appUrl}/orders/{id}"`. It supplies only the origin - the intent layer - does not know the generated app's routes, so the rest of the path is plain text plus other - placeholders, same as any other literal. Because `appUrl` is reserved, an entity must not declare a - field literally named `appUrl` (it would be shadowed in every notify block). +- **`{recordUrl}` and `{inboxUrl}` are the ready-made deep links - prefer them.** `{recordUrl}` is the + link to the record the message is about (`body: "Approve it here: {recordUrl}"`), `{inboxUrl}` the + link to the recipient's process Inbox. Both are assembled for you, so **never hand-type a route** - + the intent layer does not know the generated app's URLs, and a path typed into a body would break + the day the generated layout changes. In a fan-out (`forEach:`) `{recordUrl}` links the ROW, like + every other bare path, while `{record.}` reads the anchor record. +- **`{appUrl}` is the raw origin** (`DIRIGIBLE_APP_BASE_URL`, tenant-overridable) - reach for it only + for a link the two above cannot express, e.g. a page of your own: `"{appUrl}/services/web/..."`. + All three names are reserved, so an entity must not declare a field literally named `appUrl`, + `recordUrl` or `inboxUrl` (it would be shadowed in every notify block). - **A recipient that cannot be resolved is surfaced, not silent.** If `to` names a field/relation that does not exist, that notification or schedule is dropped and reported in the generate response's `warnings` (as well as the server log) - fix the reference so the glue is emitted. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java index d7dbb9de5d..adea3f76f6 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.generator; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -116,6 +117,32 @@ void appUrlWorksAlongsideARelationFieldInTheSameBody() { + " + entity.Id", plan.subjectExpression()); } + @Test + void recordUrlAndInboxUrlResolveToTemplateDeclaredLocalsAndAreReported() { + Map byName = libraryModel(); + NotificationSupport.Plan plan = NotificationSupport.plan(notification("ops@x.com", "Order {id}: {recordUrl} - inbox {inboxUrl}"), + byName.get("Order"), byName, Map.of()); + + // Bare identifiers, NOT expressions: the locals are declared by the events template, which is + // the only layer that knows the generated application's routes. + assertEquals("\"Order \" + entity.Id + \": \" + recordUrl + \" - inbox \" + inboxUrl", plan.subjectExpression()); + assertTrue(plan.loads() + .isEmpty(), + "a link token is not a relation - it must not register a relation load"); + assertTrue(plan.usesRecordUrl(), "the plan must report the record link so the template declares it"); + assertTrue(plan.usesInboxUrl(), "the plan must report the inbox link so the template declares it"); + } + + @Test + void anUnusedLinkIsNotReportedSoTheTemplateDeclaresNothing() { + Map byName = libraryModel(); + NotificationSupport.Plan plan = + NotificationSupport.plan(notification("ops@x.com", "Order {id}: {recordUrl}"), byName.get("Order"), byName, Map.of()); + + assertTrue(plan.usesRecordUrl()); + assertFalse(plan.usesInboxUrl(), "an unreferenced link must not be declared - a generated class carries no dead local"); + } + @Test void oneHopRelationFieldLoadsTheRelatedEntity() { Map byName = libraryModel(); diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index a3a1c63f7c..6cc607d40f 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -349,6 +349,7 @@ private static void bindNotification(Map item, Map item, Map c truthy(item, "sourceCrossModel") ? sanitize(item, "sourceModel") : str(parameters, "javaGenFolderName")); context.put("action", strOr(item, "action", "notify")); context.put("relationLoads", relationLoads(item.get("relationLoads"), parameters)); + bindDeepLinks(item, context); bindAttachLanguage(item, context, parameters); context.put("genToGenFolder", generates ? (truthy(item, "genCrossModel") ? sanitize(item, "genToModel") : str(parameters, "javaGenFolderName")) : ""); @@ -566,6 +568,7 @@ private static void bindTransition(Map item, Map context.put("javaPerspective", sanitize(item, "perspective")); context.put("notifyRelationLoads", relationLoads(item.get("notifyRelationLoads"), parameters)); context.put("javaForEachPerspective", NamingHelper.sanitizeJavaIdentifier(strOr(item, "forEachPerspective", ""))); + bindDeepLinks(item, context); bindAttachLanguage(item, context, parameters); } @@ -584,6 +587,7 @@ private static void bindSend(Map item, Map conte context.put("javaPerspective", sanitize(item, "perspective")); context.put("notifyRelationLoads", relationLoads(item.get("notifyRelationLoads"), parameters)); context.put("javaForEachPerspective", NamingHelper.sanitizeJavaIdentifier(strOr(item, "forEachPerspective", ""))); + bindDeepLinks(item, context); bindAttachLanguage(item, context, parameters); } @@ -850,6 +854,19 @@ private static void bindAttachLanguage(Map item, Map item, Map context) { + copy(context, item, "usesRecordUrl", "usesInboxUrl", "recordUrlEntity", "recordUrlKeyProperty"); + } + /** * Resolves the Java package of each one-hop relation load. A cross-model load imports from the * owner model's generation folder; a same-model one from this project's. diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template index d312d87348..bcc235cd3a 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template @@ -116,6 +116,19 @@ public class ${className}Job implements JobHandler { #else #foreach($load in $relationLoads) ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); +#end +#if($usesRecordUrl == "true") + // {recordUrl} - the deep link to the row this message is about. The ROUTE is composed HERE, + // in the layer that knows the generated application's URL layout; the intent layer + // contributes only the entity and its key, which is what keeps it path-agnostic. + String recordUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} + + "/edit"; +#end +#if($usesInboxUrl == "true") + // {inboxUrl} - the deep link to the recipient's process Inbox. + String inboxUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; #end String to = ${toExpression}; if (to == null || to.isBlank()) { diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template index 5b53410c4a..83648b2c8d 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template @@ -52,6 +52,19 @@ public class ${className}Notification implements MessageHandler { } #foreach($load in $relationLoads) ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); +#end +#if($usesRecordUrl == "true") + // {recordUrl} - the deep link to the record this message is about. The ROUTE is composed HERE, + // in the layer that knows the generated application's URL layout; the intent layer contributes + // only the entity and its key, which is what keeps it path-agnostic. + String recordUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} + + "/edit"; +#end +#if($usesInboxUrl == "true") + // {inboxUrl} - the deep link to the recipient's process Inbox. + String inboxUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; #end String to = ${toExpression}; if (to == null || to.isBlank()) { diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template index acaaebde39..bc4f93bbb9 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template @@ -152,6 +152,19 @@ public class ${className} implements JavaDelegate { #end #foreach($load in $notifyRelationLoads) ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); +#end +#if($usesRecordUrl == "true") + // {recordUrl} - the deep link to the record this message is about (the ROW in a fan-out). The + // ROUTE is composed HERE, in the layer that knows the generated application's URL layout; the + // intent layer contributes only the entity and its key, which is what keeps it path-agnostic. + String recordUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} + + "/edit"; +#end +#if($usesInboxUrl == "true") + // {inboxUrl} - the deep link to the recipient's process Inbox. + String inboxUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; #end String to = ${notifyToExpression}; if (to == null || to.isBlank()) { diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template index 5e96923096..321453b373 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template @@ -156,6 +156,20 @@ public class ${className}Transition { gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository().findById(entity.${load.fkProperty}); +#end +#if($usesRecordUrl == "true") + // {recordUrl} - the deep link to the record this message is about (the ROW in a fan-out). + // The ROUTE is composed HERE, in the layer that knows the generated application's URL + // layout; the intent layer contributes only the entity and its key, which is what keeps it + // path-agnostic. + String recordUrl = org.eclipse.dirigible.sdk.core.Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} + + "/edit"; +#end +#if($usesInboxUrl == "true") + // {inboxUrl} - the deep link to the recipient's process Inbox. + String inboxUrl = org.eclipse.dirigible.sdk.core.Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; #end String to = ${notifyToExpression}; if (to == null || to.isBlank()) { diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 1db9de2f57..158b3981a6 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -752,7 +752,10 @@ class IntentEmissionCoverageIT extends IntegrationTest { forEach: BillRecipient to: Person.email subject: "Bill {record.note}" - body: "Dear {Person.name}, the bill is attached." + # {recordUrl} in a fan-out links the ROW - what THIS message is about - + # while {record.note} above reads the anchor. Two different scopes, both + # explicit. + body: "Dear {Person.name}, the bill is attached. Your copy: {recordUrl}" attach: recordPrint next: end - { name: end, kind: end } @@ -836,7 +839,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { notify: to: ops@example.com subject: "Bill {note}" - body: "Please find the bill attached." + body: "Please find the bill attached. Open it here: {recordUrl}" attach: print # languageFrom: the counterparty decides the language the attached print renders in. languageFrom: Person.locale @@ -1697,6 +1700,12 @@ private void assertEmission() { "the item relation map carries the __label the binder renders for a bare {{Unit}}"); assertTrue(billFeeder.contains("im.put(\"Unit\", itemUnitMap)"), "the map is hung under the relation's own key on the row"); assertTrue(sendBill.contains("catch (Exception"), "a transition's mail must be fail-soft - the status flip has already committed"); + // {recordUrl}: the notification carries the link back to the record, and the ROUTE is composed + // in the template - the only layer that knows it. This is also the compile proof: an + // undeclared local would fail the whole client-Java batch, taking every REST assertion below + // with it. + assertTrue(sendBill.contains("\"/services/web/emission-test/gen/emission/index.html#/Bill/\" + entity.Id"), + "the transition's mail must compose the record's deep link from the project, gen folder, entity and key"); // (2) On a PROCESS STEP - a JavaDelegate whose work IS the message: it re-loads the trigger // record through the generated repository, resolves the recipient, and sends. The BPMN must @@ -1736,6 +1745,10 @@ private void assertEmission() { "the recipient must still resolve against the ROW - the rows ARE the recipient list"); assertTrue(shareBill.contains("\"Bill \" + source.Note"), "a {record.} placeholder must read the anchor record, not the row: " + shareBill); + // The deep link follows the SAME scoping rule as every other bare path: it links the ROW the + // message is about, not the anchor whose document rides along. + assertTrue(shareBill.contains("\"/services/web/emission-test/gen/emission/index.html#/BillRecipient/\" + entity.Id"), + "a fan-out's {recordUrl} must link the ROW, not the anchor record: " + shareBill); assertTrue(billBpmn.contains("gen.events.emission.BillFlowShareBillSend"), "the BPMN service task must bind the generated fan-out sender delegate too"); assertFalse(repository.getResource(PROJECT_PATH + "/custom/MailBill.java") 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 82800be683..f9778d91db 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 @@ -248,7 +248,9 @@ class IntentEngineIT extends IntegrationTest { event: { onUpdate: Order } to: ops@example.com subject: "Order {id} for {customer.name}, total {total}" - body: "The order changed." + # {recordUrl} - the deep link to the record. The intent never spells a route: the + # events template composes it from the entity + key the glue carries. + body: "The order changed. Open it here: {recordUrl}" schedules: - name: staleOrders @@ -259,7 +261,7 @@ class IntentEngineIT extends IntegrationTest { notify: to: ops@example.com subject: "Stale order {id} for {customer.name}" - body: "This order is stale." + body: "This order is stale. Open it: {recordUrl} - your tasks: {inboxUrl}" integrations: - name: pushOrderToWarehouse @@ -601,6 +603,18 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { "the listener should load the one-hop related entity by FK id"); assertTrue(notification.contains("\"Order \" + entity.Id + \" for \" + (customer == null ? null : customer.Name)"), "the subject should interpolate the relation.field against the loaded related entity"); + // {recordUrl}: the ROUTE is the template's knowledge, the entity + key the intent's. What the + // intent emits is the bare identifier; the local composed here is what makes the link work, and + // is the whole point of the token over hand-typing a path after {appUrl}. + assertTrue( + notification.contains("String recordUrl = Configurations.get(\"DIRIGIBLE_APP_BASE_URL\", \"\")") + && notification.contains("\"/services/web/intent-test/gen/orders/index.html#/Order/\" + entity.Id"), + "the notification should declare the recordUrl local composed from the project, gen folder, entity and key"); + assertTrue(notification.contains("+ recordUrl"), "the body should read the declared link local, not an entity field"); + assertFalse(notification.contains("entity.RecordUrl"), + "recordUrl is reserved - it must never fall through to a (non-existent) entity property"); + assertFalse(notification.contains("String inboxUrl"), + "a link the message never references must not be declared - no dead local in generated code"); // The schedule is a self-describing @Component JobHandler (cron()) that queries via a typed // Criteria and notifies per row. @@ -617,6 +631,13 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { "CustomerEntity customer = entity.Customer == null ? null : new CustomerRepository().findById(entity.Customer)"), "the per-row notify should load the one-hop related entity"); assertTrue(job.contains("Mail.send("), "the job should notify per row via the SDK Mail API"); + // Both links at the second call site: the per-row record link and the Inbox link, each declared + // only because the body names it. + assertTrue(job.contains("\"/services/web/intent-test/gen/orders/index.html#/Order/\" + entity.Id"), + "the job should compose the per-row record link from the queried entity and its key"); + assertTrue(job.contains("\"/services/web/intent-test/gen/orders/index.html#/inbox\""), + "the job should compose the Inbox link from the same base"); + assertTrue(job.contains("+ recordUrl") && job.contains("+ inboxUrl"), "the body should read both declared link locals"); // The integration is a self-describing @Component MessageHandler that forwards the entity JSON to // an external endpoint.