diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 2e1c363eec..95db243027 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -403,6 +403,14 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal entityMap.put("attachmentReadOnly", "true"); } } + // A child collection that does NOT freeze with its master. The master's immutability locks + // the document's own content; this child is a different entity with its own controller, + // which already accepts the writes - only the generated UI was extending the master's lock + // over it. Emitted (and consumed by the detail registration) only when false, so a model + // that says nothing keeps byte-identical output. + if (!entity.locksWithMaster()) { + entityMap.put("locksWithMaster", "false"); + } // Custom Java imports for the generated entity Repository (e.g. a calculated-field action's // CalculatedField class). Base64-encoded to match the EDM editor's serialization, which the // DAO template's parameterUtils decodes before emitting them into the import block. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java index b6aa5f7693..5cc27080c3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java @@ -149,6 +149,15 @@ public class EntityIntent { * {@code immutableWhen} - always-immutable subsumes any status scope. */ private Boolean immutable; + /** + * Whether this composition child freezes together with its master ({@code locksWithMaster}, default + * true). A master's {@code immutableWhen} locks the document's own CONTENT; it says nothing about a + * child collection that is a different entity with its own controller and its own rules. Declaring + * {@code locksWithMaster: false} keeps this collection's user-write affordances alive while the + * master is locked - the settlement case: an issued invoice's lines are frozen, but the payment + * allocations against it go on being recorded for months. + */ + private Boolean locksWithMaster; /** * Optional hierarchy declaration: names this entity's to-one SELF-relation that forms the tree edge * (e.g. {@code hierarchy: Parent} on a chart-of-accounts Account). The generated list renders as a @@ -484,6 +493,22 @@ public Boolean getDuplicable() { return duplicable; } + /** + * Whether this child collection freezes when its master becomes immutable. Defaults to TRUE, so an + * entity that says nothing keeps the behaviour it has always had. + */ + public boolean locksWithMaster() { + return !Boolean.FALSE.equals(locksWithMaster); + } + + public Boolean getLocksWithMaster() { + return locksWithMaster; + } + + public void setLocksWithMaster(Boolean locksWithMaster) { + this.locksWithMaster = locksWithMaster; + } + public void setDuplicable(Boolean duplicable) { this.duplicable = duplicable; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index a12c2593c3..0ef4618313 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -247,6 +247,7 @@ private static void validateFunctions(IntentModel model, List issues) { } } validateSnapshotLanguage(entity, model, compositionParent, issues); + validateLocksWithMaster(entity, model, compositionParent, issues); for (FieldIntent field : entity.getFields()) { String ff = field.getFunction(); if (ff != null && !ff.isBlank() && !FIELD_FUNCTIONS.contains(ff.trim() @@ -1417,6 +1418,35 @@ private static void validateNotifyBlock(NotificationIntent notify, String subjec * document whose copy is minted) - mutually exclusive, meaningless anywhere else. Absent both, the * mint falls back to the first entry of the tenant-resolved application language set at run time. */ + /** + * Validate {@code locksWithMaster: false} - the declaration that a child collection does NOT freeze + * when its master becomes immutable (the settlement case: an issued invoice's lines are frozen, its + * payment allocations are not). It is only meaningful on a composition child OF a master that + * actually locks, so both are required rather than silently ignored: an inert declaration reads as + * a working one, and the author only finds out when the affordance is still missing in production. + */ + private static void validateLocksWithMaster(EntityIntent entity, IntentModel model, Map compositionParent, + List issues) { + if (entity.locksWithMaster()) { + return; + } + String name = entity.getName(); + String master = compositionParent.get(name); + if (master == null) { + issues.add("entity [" + name + "] declares locksWithMaster: false but is not a composition child" + + " - only a child collection can outlive its master's lock"); + return; + } + EntityIntent parent = entityByName(model, master); + boolean masterLocks = parent == null || Boolean.TRUE.equals(parent.getImmutable()) + || (parent.getImmutableWhen() != null && !parent.getImmutableWhen() + .isBlank()); + if (!masterLocks) { + issues.add("entity [" + name + "] declares locksWithMaster: false but its master [" + master + + "] never locks (no immutableWhen / immutable) - the declaration would have no effect"); + } + } + private static void validateSnapshotLanguage(EntityIntent entity, IntentModel model, Map compositionParent, List issues) { boolean hasLanguage = entity.getLanguage() != null && !entity.getLanguage() 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 7d96ae8879..3117238b78 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 @@ -227,6 +227,18 @@ field may declare: snapshot entity (e.g. the frozen copy stored when an invoice is SENT): written once by the flow, never editable. System writes through the repository stay possible. Mutually exclusive with `immutableWhen` (always-immutable subsumes any status scope); needs no EntityStatus relation. +- `locksWithMaster: false` (entity-level, on a **composition child**) - **this collection does not + freeze with its master**. A master's immutability locks the document's own CONTENT; a child + collection is a different entity with its own controller and its own rules, and the generated UI + used to extend the master's lock over it - hiding Add and the row actions on every child panel of + a locked document. Declare it on the child that must go on being recorded: the canonical case is + **payment allocations**, where an issued invoice's lines are frozen but money keeps arriving + against it for months (settlement is a different lifecycle from content). The server already + permitted these writes - only the affordance was missing. Applies to a child rendered as its own + **panel**; a document's own line items are the document (they stay locked, and the flag would be + inert there). Requires a composition parent that actually declares `immutableWhen` / `immutable` - + both are validated, so an inert declaration fails at authoring time instead of quietly doing + nothing. - `hierarchy: ` (entity-level) - **tree entities**: names the entity's own optional to-one SELF-relation forming the tree edge (`hierarchy: Parent` with `- { name: Parent, kind: manyToOne, to: }`). The generated list renders as an diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 2e75bc8177..79c1ee5c61 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -1626,4 +1626,51 @@ void fieldFormatEmailEmitsTheEmailWidgetAndTheCanonicalPattern() { assertEquals("TEXTBOX", propertyByName(contact, "Note").get("widgetType")); assertNull(propertyByName(contact, "Note").get("widgetPattern")); } + + /** + * A child collection that does not freeze with its master carries the marker the detail + * registration reads; every other entity keeps byte-identical output (the attribute is emitted only + * when declared false). + */ + @Test + void locksWithMasterFalseMarksTheChildThatOutlivesItsMastersLock() { + String yaml = """ + name: sales + entities: + - name: Invoice + function: Document + immutableWhen: "Status == 3" + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, length: 50 } + - name: InvoiceItem + function: DocumentItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentModel parsed = IntentParser.parse(yaml); + Map model = EdmIntentGenerator.buildModelJsonForTest(parsed, "sales"); + List> entities = entities(model); + + assertEquals("false", entityByName(entities, "InvoiceAllocation").get("locksWithMaster")); + // The document's own line items keep freezing with it - that is what immutability is for. + assertNull(entityByName(entities, "InvoiceItem").get("locksWithMaster")); + assertNull(entityByName(entities, "Invoice").get("locksWithMaster")); + } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index c7fdc47ec9..c33b3e6c68 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.parser; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -2299,4 +2300,91 @@ void parallelUnknownNextIsRejected() { .anyMatch(i -> i.contains("next [nowhere] is not a declared step or `end`")), "expected an unknown-next issue, got: " + ex.getIssues()); } + + /** + * `locksWithMaster: false` only means something on a composition child - a top-level entity has no + * master whose lock it could outlive, so the declaration is rejected instead of silently ignored. + */ + @Test + void locksWithMasterIsRejectedOnAnEntityThatIsNotACompositionChild() { + String yaml = """ + name: sales + entities: + - name: Invoice + immutableWhen: "Status == 3" + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("not a composition child")), + "a non-child locksWithMaster should be rejected, got: " + ex.getIssues()); + } + + /** + * A master that never locks makes the declaration inert. That is the + * authored-but-silently-unconsumed failure mode, so it fails at authoring time rather than in + * production. + */ + @Test + void locksWithMasterIsRejectedWhenTheMasterNeverLocks() { + String yaml = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("never locks")), + "an inert locksWithMaster should be rejected, got: " + ex.getIssues()); + } + + /** The valid shape parses: a composition child of a master that does lock. */ + @Test + void locksWithMasterParsesOnACompositionChildOfALockingMaster() { + String yaml = """ + name: sales + entities: + - name: Invoice + immutableWhen: "Status == 3" + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentModel model = IntentParser.parse(yaml); + assertFalse(model.getEntities() + .stream() + .filter(e -> "InvoiceAllocation".equals(e.getName())) + .findFirst() + .orElseThrow() + .locksWithMaster()); + } } diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template index 1acb7d6c38..29b6ec433c 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template @@ -329,7 +329,7 @@

-
+
@@ -401,7 +401,7 @@ - + @@ -415,7 +415,7 @@ - + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template index 127456c75d..24a6e78ab2 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template @@ -227,7 +227,7 @@

-
+
@@ -299,7 +299,7 @@ - + @@ -313,7 +313,7 @@ - +
  • diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template index 1823758b99..2943d3a73e 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template @@ -56,6 +56,13 @@ App.registerDetail('${masterEntity}', { // uploaded files with download, and — unless read-only — an upload control + per-file remove. // Read-only (a generated Snapshot child) shows download only. See detailPanel `files`. files: { readOnly: #if($attachmentReadOnly)true#{else}false#end }, +#end +#if($locksWithMaster == "false") + // This collection does NOT freeze with its master (intent `locksWithMaster: false`): the master's + // immutability locks the document's own content, while these rows go on being recorded against it + // (an issued invoice's payment allocations). The master's read-only mode therefore does not hide + // this panel's Add / row actions. The server always allowed it - the child has its own controller. + locksWithMaster: false, #end columns: [ #foreach($property in $properties)