diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index df977539992..6025822d12d 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -758,6 +758,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Parser hardening.** A wrong-typed scalar (e.g. an unquoted brace recipient `to: {member.email}`, which YAML parses as an object) now surfaces as a clean `IntentValidationException` issue with a helpful message instead of a raw 500 Gson error that wedged the editor. `IntentParserTest` covers it. - **Externalized AI system prompt.** Moved from an inline string to `intent-assistant-guide.md` (classpath resource, fail-fast load), corrected to the full current schema incl. the glue catalog + `businessKey`/`businessKeyStrategy`, and restored the propose-the-whole-file tool contract the draft had dropped. - **Settlements re-allocate a corrected payment (#6818).** The payment spread handler used to bind the payment's bare create topic only, so a payment booked for the wrong amount and corrected afterwards - or created incomplete and completed later - was never re-allocated and the invoice kept the original settled figure. It is now emitted once per bound payment event (create + `-updated`) from its own glue collection, **`settlementListeners`** - the `settlements` collection still drives the one-per-settlement `OnInvoice` delegate, and a second collection is what lets the two templates fan out differently while sharing one descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions do). Note the two traps a new glue collection carries: it needs a `case` in `GlueGenerator` (Java - the only generator since #6707; the old `generateUtils.js` twin is gone) or it renders whole-model with raw `${...}` placeholders, and `GlueGenerator.copy` is a per-key allow-list, so a new descriptor key that is not listed there never reaches the template. The handler itself was already a recompute of the payment's *unallocated* balance (re-delivery is a no-op by construction); it now also **releases** the excess - newest allocation first, through the junction repository - when the payment is corrected below what it already covers, so the recompute converges in both directions. Two boundaries of that release: the CREATE handler never releases - a create event is the first word about a payment, so a negative pot there can only be a DELAYED create racing a correction the updated handler already allocated, and releasing on the stale payload would undo it (#6865). And a corrected MATCH column (the payment re-filed under another Customer) is invisible to the amount-based recompute (`pot - allocated == 0`), so the payment's match columns are grouping keys and a third listener on the payment's `-rekeyed` topic releases everything and re-allocates from the STORE - both re-key notices run the same store-driven recompute, so delivery order cannot matter; emitted only for a LOCAL payment, since a cross-model payment's DAO belongs to the owner model and a projection has no repository to re-read (#6864). +- **What a Duplicate does NOT copy (`duplicable` object form, #7358).** `duplicable: true` cloned every ordinary user field of the header, so a copied invoice kept the source's `date`, `due` and `taxEventDate` - "same invoice as last month" opened dated last month, and the module could not fix it on its own side: a `calculatedActionOnCreate` fills an EMPTY value and respects a present one by contract, which is exactly what makes the copied value stick. The key now also takes `{ defaults: {...}, reset: [...] }` - `reset` drops a field from the clone so the create path fills it as it would on a hand-made document, `defaults` writes a constant, with the same `now` token and the same field-shape rendering `generates.defaults` has (`date` -> `YYYY-MM-DD`, `month` -> `YYYY-MM`, `week` -> `YYYY-Www`). The shorthand is normalized to the empty object on the RAW tree (`IntentParser.normalizeDuplicable`, the `expandUniqueShorthand` precedent) so one typed class carries both forms and the unknown-key walk sees the two keys; `EntityIntent.duplicable` is therefore a `DuplicateIntent`, not a `Boolean`. Both halves reach the document template as **structured** `.edm` attributes (`duplicateReset`, `duplicateDefaults` in `STRUCTURED_ATTRIBUTES` and in `transform-edm.js`'s `ENTITY_STRUCTURED`) - entity metadata a flat attribute cannot carry is lost on the next modeler save (#6826), which here would silently put the copied dates back. Refused at parse, each because accepting it would be silent: a name that is neither a field nor a to-one of the entity, one of the built-in drops (identity, `number:`, `function: EntityStatus`, `readOnly`, `aggregate`), the same name in both lists, `now` on a property that is not a date/month/week, and a `reset` of a **required** field with no `defaultValue` and no create-time rule - that one would make every duplicate fail on the server's own "field is required". The `now` value renders from the LOCAL calendar fields in `todayAs`, never `toISOString()`: east of Greenwich that is yesterday after the evening cut-over. Out of scope and still true: the copy is client-side and not atomic - a failed line POST leaves a half-copied draft. - **CI runs on Corretto 24** (compile target stays 21); the integration-test fork gets `-Xmx6g`. (Root-level change; recorded here because it landed alongside the intent work.) **Cross-artefact field naming:** the `.form` control `model` (and control `id`) bind to the entity property, so they use `IntentNaming.pascalCase` to match the EDM property names (`loanedOn` -> `LoanedOn`). The `.report` references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index acdf78c4aeb..a10b1fa23c2 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -95,7 +95,9 @@ entities: Entity-level extras: `order: [Id, Product, Quantity, ...]` sequences form controls/list columns; `duplicable: true` adds a Duplicate button on a document (clones header + items through the normal -create path); `imports: |` injects Java import lines into the generated repository (pairs with +create path), and its object form says what the copy must NOT carry over - `duplicable: { defaults: { +date: now }, reset: [due, taxEventDate] }`, where `reset` hands a field back to the entity's own +create-time rule and `defaults` writes a constant (`now` is today in the field's own shape); `imports: |` injects Java import lines into the generated repository (pairs with calculated actions); `aggregate: true` on a document master's numeric field keeps it equal to the sum of the items' same-named field (the totals footer). 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 dc06ac8b5fd..0b7e337892e 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 @@ -366,6 +366,18 @@ private static EdmDocument buildDocument(IntentGenerationContext context, Intent // current document (header + line items) into a new draft (see the document template). if (entity.isDuplicable()) { entityMap.put("duplicable", "true"); + // What the copy must NOT carry over from the source (#7358): the fields handed back + // to the entity's own create-time rule, and the constants written into the clone. + // Without them every ordinary user field rides along, so "same invoice as last + // month" opens dated last month, due last month, with last month's tax event. + List resets = duplicateResets(entity); + if (!resets.isEmpty()) { + entityMap.put("duplicateReset", resets); + } + List> constants = duplicateDefaults(entity); + if (!constants.isEmpty()) { + entityMap.put("duplicateDefaults", constants); + } } // Chat items: render the line-items pane as a conversation thread instead of the editable // table. Resolve which child property is the message body (and the optional internal @@ -3523,6 +3535,101 @@ private static Integer defaultLength(String dataType) { * out deterministically in a grid so re-generation is byte-stable. */ @SuppressWarnings("unchecked") + /** + * The generated property names a Duplicate drops from the cloned header, in authored order. Each is + * handed back to the create path, which fills it exactly as it would on a hand-made document (a + * {@code calculatedActionOnCreate}, a {@code defaultValue}). + * + * @param entity the duplicable document master + * @return the PascalCase property names, never null + */ + private static List duplicateResets(EntityIntent entity) { + List resets = new ArrayList<>(); + for (String name : entity.getDuplicable() + .getReset()) { + if (notBlank(name)) { + resets.add(IntentNaming.pascalCase(name.trim())); + } + } + return resets; + } + + /** + * The constants a Duplicate writes into the cloned header, as {@code {name, shape, js}} entries in + * authored order. {@code shape} is {@code date} / {@code month} / {@code week} for the {@code now} + * token - today in the field's own shape, rendered by the document page's {@code todayAs} helper + * against the LOCAL clock - and {@code literal} otherwise, where {@code js} carries the value + * already coerced to the property's type as a JavaScript literal. + * + * @param entity the duplicable document master + * @return the entries, never null + */ + private static List> duplicateDefaults(EntityIntent entity) { + List> defaults = new ArrayList<>(); + for (Map.Entry assignment : entity.getDuplicable() + .getDefaults() + .entrySet()) { + String name = assignment.getKey(); + String value = assignment.getValue(); + if (!notBlank(name) || !notBlank(value)) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", IntentNaming.pascalCase(name.trim())); + String type = duplicateDefaultType(entity, name.trim()); + if ("now".equals(value.trim())) { + entry.put("shape", "month".equals(type) || "week".equals(type) ? type : "date"); + entry.put("js", ""); + } else { + entry.put("shape", "literal"); + entry.put("js", duplicateLiteral(value.trim(), type)); + } + defaults.add(entry); + } + return defaults; + } + + /** + * The authored type of the named field, or {@code integer} for a to-one relation (a default on a + * relation assigns its raw foreign key). Blank when the name resolves to neither - the parser has + * already refused that, so generation never has to. + */ + private static String duplicateDefaultType(EntityIntent entity, String name) { + for (FieldIntent field : entity.getFields()) { + if (name.equalsIgnoreCase(field.getName())) { + return field.getType() == null ? "" + : field.getType() + .toLowerCase(Locale.ROOT); + } + } + for (RelationIntent relation : entity.getRelations()) { + if (name.equalsIgnoreCase(relation.getName())) { + return "integer"; + } + } + return ""; + } + + /** + * A literal {@code duplicable.defaults} value as the JavaScript source the document page assigns: a + * number for a numeric property, {@code true} / {@code false} for a boolean, a quoted string + * otherwise. The property's declared type decides, not the value's shape - a string field holding + * {@code "01"} must stay the string it was authored as. + */ + private static String duplicateLiteral(String value, String type) { + switch (type) { + case "integer": + case "long": + case "double": + case "decimal": + return value; + case "boolean": + return Boolean.toString(Boolean.parseBoolean(value)); + default: + return STRUCTURED_JSON.toJson(value); + } + } + private static String renderEdmXml(EdmDocument document) { Map body = (Map) document.modelJson.get("model"); List> entities = (List>) body.get("entities"); @@ -4011,9 +4118,9 @@ private static String sanitizeId(String raw) { * {@code transform-edm} rebuilds into {@code uniqueConstraints}. Emitting it here too would write * it twice and round-trip it as a duplicate. */ - private static final Set STRUCTURED_ATTRIBUTES = - Set.of("rollupGuard", "checks", "labelParts", "aggregateKeys", "groupingKeys", "relatedEntities", "scopedCalendars", - "lifecycleStatusNameList", "lookupColumns", "languages", "widgets", "customActionLabels", "processTaskLabels"); + private static final Set STRUCTURED_ATTRIBUTES = Set.of("rollupGuard", "checks", "labelParts", "aggregateKeys", "groupingKeys", + "relatedEntities", "scopedCalendars", "lifecycleStatusNameList", "duplicateReset", "duplicateDefaults", "lookupColumns", + "languages", "widgets", "customActionLabels", "processTaskLabels"); /** * Compact, non-HTML-escaping JSON for the structured {@code .edm} attributes. Compact so the value diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/DuplicateIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/DuplicateIntent.java new file mode 100644 index 00000000000..fdeb560e289 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/DuplicateIntent.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.model; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * What a document's built-in Duplicate action does to the copied header, beyond the drops it + * has always made (identity, audit columns, status, document number, read-only and aggregate + * fields, which are not authorable). + * + *

+ * Authored as the object form of the entity's {@code duplicable} key; the shorthand + * {@code duplicable: true} is the empty object, so an entity that says nothing keeps today's + * behaviour exactly - every ordinary user field is copied. + * + *

+ * duplicable:
+ *   defaults: { date: now }        # constants written into the clone
+ *   reset: [due, taxEventDate]     # dropped, so the entity's own create-time rule refills them
+ * 
+ * + *

+ * The two keys compose and never overlap: {@code reset} is for a field that HAS a create-time rule + * (a {@code calculatedActionOnCreate}, a {@code defaultValue}) and must be handed back to it - a + * copied value would be respected by that rule and stick; {@code defaults} is for a field that has + * none, where the copy needs a value stated here. {@code now} renders today in the field's own + * shape, the same token and the same rendering {@code generates.defaults} uses. + */ +public class DuplicateIntent { + + /** + * Constants written into the cloned header after the resets, by the entity's own field / to-one + * relation name. {@code now} is today in the field's shape ({@code date} -> {@code YYYY-MM-DD}, a + * {@code month} field -> {@code YYYY-MM}, a {@code week} field -> {@code YYYY-Www}); any other + * value is a literal coerced to the property's type. + */ + private Map defaults = new LinkedHashMap<>(); + + /** + * The entity's own field / to-one relation names dropped from the clone, so the create it posts + * fills them exactly as it would on a hand-made document. + */ + private List reset = new ArrayList<>(); + + /** The constants written into the clone, keyed by authored property name; never null. */ + public Map getDefaults() { + return defaults == null ? new LinkedHashMap<>() : defaults; + } + + public void setDefaults(Map defaults) { + this.defaults = defaults == null ? new LinkedHashMap<>() : defaults; + } + + /** The authored property names dropped from the clone; never null. */ + public List getReset() { + return reset == null ? new ArrayList<>() : reset; + } + + public void setReset(List reset) { + this.reset = reset == null ? new ArrayList<>() : reset; + } +} 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 387de1736a4..36467945ba3 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 @@ -105,8 +105,14 @@ public class EntityIntent { * draft and opens it. The clone creates through the normal REST create path, so the number * ({@code calculatedActionOnCreate}), the initial status ({@code init}) and calculated fields are * reassigned by the server. Absent (the default) → no Duplicate action. + * + *

+ * Authored either as the shorthand {@code duplicable: true} or as the object form + * {@code duplicable: { defaults: {...}, reset: [...] }}, which says which fields the copy must NOT + * carry over from the source (the invoice's date, due date and tax-event date). The parser + * normalizes the shorthand to an empty object, so both arrive here as this type. */ - private Boolean duplicable; + private DuplicateIntent duplicable; /** * Optional explicit ordering of the generated UI controls (form inputs, list columns, detail rows) * by property name - fields and to-one relations interleaved, in the given order. Names match the @@ -618,10 +624,10 @@ public void setMultilingual(Boolean multilingual) { * ({@code duplicable: true}). */ public boolean isDuplicable() { - return Boolean.TRUE.equals(duplicable); + return duplicable != null; } - public Boolean getDuplicable() { + public DuplicateIntent getDuplicable() { return duplicable; } @@ -641,7 +647,7 @@ public void setLocksWithMaster(Boolean locksWithMaster) { this.locksWithMaster = locksWithMaster; } - public void setDuplicable(Boolean duplicable) { + public void setDuplicable(DuplicateIntent 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 2a86feb08b2..6de7acd63b3 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 @@ -46,6 +46,7 @@ import org.eclipse.dirigible.components.intent.model.AggregateIntent; import org.eclipse.dirigible.components.intent.model.CustomWidgetIntent; import org.eclipse.dirigible.components.intent.model.DependsOnIntent; +import org.eclipse.dirigible.components.intent.model.DuplicateIntent; import org.eclipse.dirigible.components.intent.model.NumberIntent; import org.eclipse.dirigible.components.intent.model.CalendarIntent; import org.eclipse.dirigible.components.intent.model.CheckIntent; @@ -336,6 +337,7 @@ public static IntentModel parse(String yaml) { rejectLifecycleOn(tree); moveGeneratesItemLines(tree); expandUniqueShorthand(tree); + normalizeDuplicable(tree); // A key the typed model does not declare is dropped by the Gson mapping without a sound, so it // is collected here - on the raw tree, while the author's spelling still exists - and reported // together with the structural issues below. @@ -395,6 +397,7 @@ private static void validate(IntentModel model, List issues) { validateFunctions(model, issues); validateViews(model, issues); validateDocumentItemsLayout(model, issues); + validateDuplicable(model, issues); validateOrders(model, issues); validateProcesses(model, entityNames, issues); validateForms(model, entityNames, issues); @@ -579,6 +582,156 @@ private static EntityIntent itemsChild(IntentModel model, Map co return compositionChildren == 1 ? sole : null; } + /** Whether the given value is present and not blank. */ + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + /** + * Validate an entity's {@code duplicable} object form: every name it mentions must be a property of + * the entity the copy is made of, must be one the copy actually carries, and must end up with a + * value. + * + *

+ * The built-in drops (identity, audit, status, number, read-only, aggregate) were never authorable + * and stay that way: naming one is refused rather than accepted and ignored, or an author would + * believe they control something the Duplicate action decided long before reading this block. + * + * @param model the typed model + * @param issues collected issues + */ + private static void validateDuplicable(IntentModel model, List issues) { + for (EntityIntent entity : model.getEntities()) { + DuplicateIntent duplicate = entity.getDuplicable(); + if (entity.getName() == null || duplicate == null) { + continue; + } + String subject = "entity [" + entity.getName() + "] duplicable"; + Set reset = new LinkedHashSet<>(); + for (String name : duplicate.getReset()) { + if (name == null || name.isBlank()) { + issues.add(subject + ".reset has a blank entry"); + continue; + } + reset.add(name.trim() + .toLowerCase(Locale.ROOT)); + validateDuplicableProperty(entity, subject + ".reset", name.trim(), true, null, issues); + } + for (Map.Entry assignment : duplicate.getDefaults() + .entrySet()) { + String name = assignment.getKey(); + if (name == null || name.isBlank()) { + issues.add(subject + ".defaults has a blank key"); + continue; + } + if (reset.contains(name.trim() + .toLowerCase(Locale.ROOT))) { + issues.add(subject + " names [" + name.trim() + "] in both reset and defaults - a field is either handed back to the" + + " entity's create-time rule or assigned here, never both"); + continue; + } + validateDuplicableProperty(entity, subject + ".defaults", name.trim(), false, assignment.getValue(), issues); + } + } + } + + /** + * One {@code reset} entry or {@code defaults} key: it must be a field or a to-one relation of this + * entity, must not be one of the built-in drops, and - for a {@code reset} - must be a value the + * create it posts can supply on its own. + * + * @param entity the entity being duplicated + * @param subject the message prefix (the block and key being checked) + * @param name the authored property name + * @param isReset whether this is a {@code reset} entry (else a {@code defaults} key) + * @param value the authored default value, for a {@code defaults} key + * @param issues collected issues + */ + private static void validateDuplicableProperty(EntityIntent entity, String subject, String name, boolean isReset, String value, + List issues) { + for (FieldIntent field : entity.getFields()) { + if (!name.equalsIgnoreCase(field.getName())) { + continue; + } + if (field.isPrimaryKey() || "uuid".equalsIgnoreCase(field.getType())) { + issues.add(subject + " names [" + name + "] - the record's identity is minted by the server and never copied"); + return; + } + if (field.getNumber() != null) { + issues.add(subject + " names [" + name + "] - the document number is minted by the server and never copied"); + return; + } + if (field.isAggregate()) { + issues.add(subject + " names [" + name + "] - an aggregate is derived from the lines and never copied"); + return; + } + if (field.isReadOnly()) { + issues.add(subject + " names [" + name + "] - a readOnly field is never copied"); + return; + } + if (isReset) { + validateDuplicableReset(subject, name, field, issues); + } else { + validateDuplicableDefault(subject, name, field.getType(), value, issues); + } + return; + } + for (RelationIntent relation : entity.getRelations()) { + if (!name.equalsIgnoreCase(relation.getName())) { + continue; + } + if (!"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { + issues.add(subject + " names [" + name + "] - only a field or a to-one relation is copied, so only one can be reset or" + + " defaulted"); + return; + } + if (relation.isEntityStatus()) { + issues.add(subject + " names [" + name + "] - the status of a copy is the lifecycle's initial one and never copied"); + return; + } + if (!isReset) { + validateDuplicableDefault(subject, name, "integer", value, issues); + } + return; + } + issues.add(subject + " names [" + name + "] which is not a field or a to-one relation of entity [" + entity.getName() + "]"); + } + + /** + * A {@code reset} hands the field back to the create path, so the create has to be able to fill it. + * A required field with neither a {@code defaultValue} nor a create-time rule would make every + * duplicate fail with the server's own "field is required" - at authoring time that is a mistake, + * not a decision. + */ + private static void validateDuplicableReset(String subject, String name, FieldIntent field, List issues) { + boolean filled = + hasText(field.getDefaultValue()) || hasText(field.getCalculatedActionOnCreate()) || hasText(field.getCalculatedOnCreate()); + if (field.isRequired() && !filled) { + issues.add(subject + " names required field [" + name + "], which has no defaultValue and no create-time rule - resetting it" + + " would make every duplicate fail; give it a defaults: value or a create-time rule"); + } + } + + /** + * A {@code defaults} value: {@code now} is today in the field's own shape, so it is only meaningful + * on a field that HOLDS a date - the same rule and the same wording {@code generates.defaults} + * uses. Anything else is a literal, coerced to the property's type at generation. + */ + private static void validateDuplicableDefault(String subject, String name, String type, String value, List issues) { + if (value == null || value.isBlank()) { + issues.add(subject + " assigns [" + name + "] a blank value - give it a value or list it under reset:"); + return; + } + if (!"now".equals(value.trim())) { + return; + } + String kind = type == null ? "" : type.toLowerCase(Locale.ROOT); + if (!"date".equals(kind) && !"month".equals(kind) && !"week".equals(kind)) { + issues.add(subject + " assigns [" + name + "] the value now, but that property is not a date - now is today in the field's own" + + " shape, so it is only a value for a date / month / week field"); + } + } + /** * Validate the optional {@code documentItemsLayout} selector on a document master: the only * supported value is {@code chat}; the entity must resolve a line-items child; and that child must @@ -5521,6 +5674,45 @@ private static void rehomeItemLines(Object generateNode) { * * @param tree the SnakeYAML-loaded raw tree */ + /** + * Normalize an entity's {@code duplicable} key to the object form the typed model maps: + * {@code true} becomes the empty mapping (no resets, no defaults - today's behaviour exactly) and + * {@code false} is removed, so nothing downstream has to know that the key was ever a boolean. + * + *

+ * Done on the raw tree, before the unknown-key walk, for the reason {@link #expandUniqueShorthand} + * is: a shorthand and a full form share ONE typed class, and Gson maps a boolean onto an object + * with an exception rather than a message an author can act on. The walk then sees {@code defaults} + * / {@code reset} as declared fields of that class. + * + * @param tree the SnakeYAML-loaded raw tree + */ + @SuppressWarnings("unchecked") + private static void normalizeDuplicable(Object tree) { + if (!(tree instanceof Map root) || !(root.get("entities") instanceof List entities)) { + return; + } + List issues = new ArrayList<>(); + for (Object entityNode : entities) { + if (!(entityNode instanceof Map entity) || !entity.containsKey("duplicable")) { + continue; + } + Object declared = entity.get("duplicable"); + Map writable = (Map) entity; + if (declared == null || Boolean.FALSE.equals(declared)) { + writable.remove("duplicable"); + } else if (Boolean.TRUE.equals(declared)) { + writable.put("duplicable", new LinkedHashMap<>()); + } else if (!(declared instanceof Map)) { + issues.add("entity [" + entity.get("name") + "] duplicable [" + declared + + "] is neither true/false nor a mapping - the object form takes defaults: and reset:"); + } + } + if (!issues.isEmpty()) { + throw new IntentValidationException(issues); + } + } + private static void expandUniqueShorthand(Object tree) { if (!(tree instanceof Map root)) { return; 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 30abb1c9285..817af913106 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 @@ -747,6 +747,33 @@ through the normal create path so the number (`calculatedActionOnCreate`), the i source's identity/system/status fields are dropped). Use it for documents users routinely copy (invoices, orders). It has no effect on non-document entities. +Everything else is copied, which is wrong for exactly the fields a business rule says must be fresh: +copied verbatim, "same invoice as last month" opens dated last month, due last month, with last +month's tax event - and a `calculatedActionOnCreate` cannot repair it, because those fill an EMPTY +value and respect a present one. Say so with the object form: + +```yaml +- name: SalesInvoice + duplicable: + defaults: { date: now } # constants written into the clone + reset: [due, taxEventDate] # dropped, so the entity's own create-time rule refills them +``` + +`reset:` is for a field that HAS a create-time rule (a `calculatedActionOnCreate`, a `defaultValue`) +and must be handed back to it; `defaults:` is for a field that has none, where the copy needs a value +stated here. `now` is today in the field's own shape (a `date` field -> `YYYY-MM-DD`, a `month` field +-> `YYYY-MM`, a `week` field -> `YYYY-Www`), the same token `generates.defaults` takes; any other +value is a literal coerced to the property's type. Both keys name the entity's own fields and to-one +relations - no `relation.field` paths. + +Refused at parse: a name that is neither a field nor a to-one relation of the entity; one that is +already dropped anyway (the primary key, the `number:` field, the `function: EntityStatus` relation, +a `readOnly` or an `aggregate` field) - naming it would let you believe you control something the +Duplicate decided long before reading the block; the same name in both lists; `now` on a property +that is not a date / month / week; and a `reset` on a **required** field with neither a +`defaultValue` nor a create-time rule, which would make every duplicate fail on the server's own +"field is required". + **Control order (`order:`):** by default the generated UI controls (form inputs, list columns, detail rows) follow the declaration order - all fields first, then the to-one relations, so relations end up last. Give an entity an `order:` list of property names to sequence them explicitly, interleaving 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 2c20bd97a6e..ab9690d5f12 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 @@ -291,6 +291,94 @@ void salesInvoiceModelsCrossModelNToMAndCalculatedNumber() { assertEquals("MANAGE_DETAILS", entityByName(entities, "SalesInvoiceItem").get("layoutType")); } + /** + * #7358: a Duplicate copied every ordinary user field, so "same invoice as last month" opened dated + * last month, due last month, with last month's tax event. The object form of {@code duplicable} + * says which fields the copy resets and which it assigns, and both halves have to reach the + * document template - and survive the {@code .edm} round-trip, or an unrelated modeler save would + * silently put the defect back. + */ + @Test + void duplicableObjectFormEmitsTheResetsAndTheDefaults() { + String yaml = """ + name: sales-invoices + entities: + - name: SalesInvoice + duplicable: + defaults: { date: now, note: "Copy", period: now } + reset: [due] + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date, required: true } + - { name: due, type: date, calculatedActionOnCreate: custom.DueDate } + - { name: note, type: string } + - { name: period, type: month } + - name: SalesInvoiceItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: salesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + """; + IntentModel parsed = IntentParser.parse(yaml); + + Map invoice = + entityByName(entities(EdmIntentGenerator.buildModelJsonForTest(parsed, "sales-invoices")), "SalesInvoice"); + assertEquals("true", invoice.get("duplicable")); + assertEquals(List.of("Due"), invoice.get("duplicateReset"), "a reset is carried as the GENERATED property name"); + + List> defaults = (List>) invoice.get("duplicateDefaults"); + assertEquals(3, defaults.size(), "every default reaches the template, in authored order"); + assertEquals("Date", defaults.get(0) + .get("name")); + assertEquals("date", defaults.get(0) + .get("shape"), + "now on a date field renders as today in that field's shape"); + assertEquals("Note", defaults.get(1) + .get("name")); + assertEquals("literal", defaults.get(1) + .get("shape")); + assertEquals("\"Copy\"", defaults.get(1) + .get("js"), + "a string literal reaches the page quoted, not bare"); + assertEquals("month", defaults.get(2) + .get("shape"), + "a month field gets the YYYY-MM shape, not a full date"); + + // Both keys are structured, so they must be written as JSON attributes rather than dropped - + // what the .edm cannot say is lost on the next modeler save (#6826). + String edm = EdmIntentGenerator.buildEdmXmlForTest(parsed, "sales-invoices"); + assertTrue(edm.contains("duplicateReset=\"["Due"]\""), () -> "the .edm must carry the resets: " + edm); + assertTrue(edm.contains("duplicateDefaults=\"["), () -> "the .edm must carry the defaults: " + edm); + } + + /** The boolean shorthand must keep generating exactly what it always did - nothing extra. */ + @Test + void duplicableShorthandEmitsNoRules() { + String yaml = """ + name: sales-invoices + entities: + - name: SalesInvoice + duplicable: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - name: SalesInvoiceItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: salesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + """; + + Map invoice = entityByName( + entities(EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "sales-invoices")), "SalesInvoice"); + + assertEquals("true", invoice.get("duplicable")); + assertNull(invoice.get("duplicateReset")); + assertNull(invoice.get("duplicateDefaults")); + } + @Test void attachmentChildInjectsFileMetadataAndIsMarked() { String yaml = """ diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/DuplicableIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/DuplicableIntentTest.java new file mode 100644 index 00000000000..8fb5fc5e8a7 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/DuplicableIntentTest.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.junit.jupiter.api.Test; + +/** + * The object form of {@code duplicable} (#7358): which fields a copy must NOT carry over from the + * source. Each refusal below exists because the same authoring mistake, accepted, produces a + * duplicate that is silently wrong (a copy dated last month) or one the server rejects every time. + */ +class DuplicableIntentTest { + + private static final String INVOICES = """ + name: invoices + entities: + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: SalesInvoiceStatus + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: SalesInvoice + duplicable: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: Sales Invoice } } + - { name: date, type: date, required: true } + - { name: due, type: date, calculatedActionOnCreate: custom.DueDate } + - { name: taxEventDate, type: date, calculatedActionOnCreate: custom.TaxEventDate } + - { name: note, type: string } + - { name: total, type: decimal, aggregate: true } + - { name: printedAt, type: date, readOnly: true } + - { name: period, type: month } + relations: + - { name: customer, kind: manyToOne, to: Customer, required: true } + - { name: status, kind: manyToOne, to: SalesInvoiceStatus, function: EntityStatus } + - name: SalesInvoiceItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: salesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + """; + + private static final String OBJECT_FORM = """ + duplicable: + defaults: { date: now } + reset: [due, taxEventDate] + """.stripTrailing(); + + @Test + void theBooleanShorthandStillParsesAndCarriesNoRules() { + EntityIntent invoice = entity(IntentParser.parse(INVOICES), "SalesInvoice"); + + assertTrue(invoice.isDuplicable(), "duplicable: true is still the shorthand for the Duplicate action"); + assertNotNull(invoice.getDuplicable(), "the shorthand normalizes to the empty object form"); + assertTrue(invoice.getDuplicable() + .getReset() + .isEmpty()); + assertTrue(invoice.getDuplicable() + .getDefaults() + .isEmpty(), + "an entity that says nothing keeps today's copy-everything behaviour"); + } + + @Test + void falseIsNotDuplicable() { + EntityIntent invoice = entity(IntentParser.parse(INVOICES.replace("duplicable: true", "duplicable: false")), "SalesInvoice"); + + assertFalse(invoice.isDuplicable()); + } + + @Test + void theObjectFormParsesBothKeys() { + EntityIntent invoice = entity(IntentParser.parse(objectForm()), "SalesInvoice"); + + assertTrue(invoice.isDuplicable()); + assertEquals(List.of("due", "taxEventDate"), invoice.getDuplicable() + .getReset()); + assertEquals(Map.of("date", "now"), invoice.getDuplicable() + .getDefaults()); + } + + @Test + void aNameThatIsNeitherFieldNorRelationIsRejected() { + assertIssue(objectForm("reset: [dueDate]"), "names [dueDate] which is not a field or a to-one relation of entity [SalesInvoice]"); + } + + @Test + void theDocumentNumberIsRejectedBecauseItIsAlreadyDropped() { + assertIssue(objectForm("reset: [number]"), "the document number is minted by the server and never copied"); + } + + @Test + void thePrimaryKeyIsRejectedBecauseItIsAlreadyDropped() { + assertIssue(objectForm("reset: [id]"), "the record's identity is minted by the server and never copied"); + } + + @Test + void anAggregateIsRejectedBecauseItIsAlreadyDropped() { + assertIssue(objectForm("reset: [total]"), "an aggregate is derived from the lines and never copied"); + } + + @Test + void aReadOnlyFieldIsRejectedBecauseItIsAlreadyDropped() { + assertIssue(objectForm("reset: [printedAt]"), "a readOnly field is never copied"); + } + + @Test + void theStatusRelationIsRejectedBecauseItIsAlreadyDropped() { + assertIssue(objectForm("reset: [status]"), "the status of a copy is the lifecycle's initial one and never copied"); + } + + @Test + void aNameInBothResetAndDefaultsIsRejectedAsAContradiction() { + assertIssue(objectForm("defaults: { due: now }\n reset: [due]"), "names [due] in both reset and defaults"); + } + + @Test + void nowOnANonDateFieldIsRejected() { + assertIssue(objectForm("defaults: { note: now }"), "now is today in the field's own shape"); + } + + @Test + void nowOnAMonthFieldIsAccepted() { + EntityIntent invoice = entity(IntentParser.parse(objectForm("defaults: { period: now }")), "SalesInvoice"); + + assertEquals(Map.of("period", "now"), invoice.getDuplicable() + .getDefaults()); + } + + @Test + void resettingARequiredFieldWithNoCreateTimeRuleIsRejected() { + assertIssue(objectForm("reset: [date]"), "would make every duplicate fail"); + } + + @Test + void resettingARequiredRelationIsAccepted() { + // A relation carries no create-time rule to check, and a copy that deliberately asks for a new + // counterparty is a legitimate thing to author. + EntityIntent invoice = entity(IntentParser.parse(objectForm("reset: [customer]")), "SalesInvoice"); + + assertEquals(List.of("customer"), invoice.getDuplicable() + .getReset()); + } + + @Test + void aScalarThatIsNeitherBooleanNorMappingIsRejected() { + assertIssue(INVOICES.replace("duplicable: true", "duplicable: reset"), "is neither true/false nor a mapping"); + } + + @Test + void anUnknownKeyInsideTheObjectFormIsReported() { + assertIssue(objectForm("resets: [due]"), "resets"); + } + + private static String objectForm() { + return INVOICES.replace(" duplicable: true", OBJECT_FORM); + } + + private static String objectForm(String body) { + return INVOICES.replace(" duplicable: true", " duplicable:\n " + body); + } + + private static void assertIssue(String yaml, String expected) { + IntentValidationException exception = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + + assertTrue(exception.getMessage() + .contains(expected), + () -> "expected an issue containing [" + expected + "] but got: " + exception.getMessage()); + } + + private static EntityIntent entity(IntentModel model, String name) { + return model.getEntities() + .stream() + .filter(entity -> name.equals(entity.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("no entity [" + name + "]")); + } +} 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-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template index 5a1d46f3d6b..d309f64d818 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template @@ -696,6 +696,24 @@ document.addEventListener('alpine:init', () => { '${docNumberProp}', #end ].forEach(f => { delete header[f]; }); +#if($duplicateReset) + // `duplicable.reset`: handed back to the create path, which fills each of these exactly as it + // would on a hand-made document (a calculatedActionOnCreate, a defaultValue) - a copied value + // would be respected by that rule and stick. +#foreach($r in $duplicateReset) + delete header['${r}']; +#end +#end +#if($duplicateDefaults) + // `duplicable.defaults`: constants the copy carries instead of the source's values. +#foreach($d in $duplicateDefaults) +#if($d.shape == "literal") + header['${d.name}'] = ${d.js}; +#else + header['${d.name}'] = this.todayAs('${d.shape}'); +#end +#end +#end const created = await App.services.api.post(this.apiPath, header); const newId = created && (created.${primaryKeysString} !== undefined ? created.${primaryKeysString} : created.id); if (newId == null) throw new Error('No id returned for the duplicated document.'); @@ -718,6 +736,25 @@ document.addEventListener('alpine:init', () => { this.state = 'ready'; } }, + + // Today in a date field's own shape, for a `duplicable.defaults` value of `now`. Built from the + // LOCAL calendar fields, never from toISOString(): that is UTC, so east of Greenwich every copy + // made after the evening cut-over would be dated yesterday. + todayAs(shape) { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + if (shape === 'month') return now.getFullYear() + '-' + pad(now.getMonth() + 1); + if (shape === 'week') { + // ISO-8601 week: Thursday of the current week decides both the week number and its year. + const thursday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + thursday.setDate(thursday.getDate() + 3 - ((thursday.getDay() + 6) % 7)); + const firstThursday = new Date(thursday.getFullYear(), 0, 4); + firstThursday.setDate(firstThursday.getDate() + 3 - ((firstThursday.getDay() + 6) % 7)); + const week = 1 + Math.round((thursday - firstThursday) / (7 * 24 * 60 * 60 * 1000)); + return thursday.getFullYear() + '-W' + pad(week); + } + return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()); + }, #end // ----- Items (read-only table + add/edit dialog, server-side) -------------------------------- diff --git a/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/template/transform-edm.js b/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/template/transform-edm.js index 823a53324b4..3b273111c11 100644 --- a/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/template/transform-edm.js +++ b/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/template/transform-edm.js @@ -22,7 +22,7 @@ import { XML } from "@aerokit/sdk/utils"; // uniqueConstraints is intentionally NOT here: the composite-unique-key feature emits it as a // / section that transformUniqueKey (below) rebuilds, so parsing it here too would // duplicate it (#6826). -const ENTITY_STRUCTURED = ['rollupGuard', 'checks', 'labelParts', 'aggregateKeys', 'groupingKeys', 'relatedEntities', 'scopedCalendars', 'lifecycleStatusNameList']; +const ENTITY_STRUCTURED = ['rollupGuard', 'checks', 'labelParts', 'aggregateKeys', 'groupingKeys', 'relatedEntities', 'scopedCalendars', 'lifecycleStatusNameList', 'duplicateReset', 'duplicateDefaults']; const PROPERTY_STRUCTURED = ['lookupColumns']; // Document level (#6882): the structured values the .model carries ABOVE its entities. const MODEL_STRUCTURED = ['languages', 'widgets', 'customActionLabels', 'processTaskLabels']; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EdmModelRoundTripIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EdmModelRoundTripIT.java index dc047bbabf7..c4a858c2cc6 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EdmModelRoundTripIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EdmModelRoundTripIT.java @@ -160,6 +160,24 @@ class EdmModelRoundTripIT extends IntegrationTest { - { name: seatsTaken, type: integer } - { name: seatsFree, type: integer } + # A duplicable document (#7358): its object form is entity metadata the .edm has to carry, + # or an unrelated modeler save silently puts back the copy that keeps the source's dates. + - name: Order + duplicable: + defaults: { orderedOn: now } + reset: [dueOn] + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: orderedOn, type: date } + - { name: dueOn, type: date, calculatedActionOnCreate: custom.DueDate } + + - name: OrderItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: integer } + relations: + - { name: Order, kind: manyToOne, to: Order, composition: true } + - name: Booking checks: - { kind: exactlyOne, fields: [seats, waitlistSeats], message: "Either a seat or a waitlist seat" } @@ -260,7 +278,8 @@ void structured_values_survive_the_edm_to_model_round_trip() { // otherwise there is nothing for the transform to read back. uniqueConstraints is excluded here: // it is owned by the composite-unique-key feature, which emits it as a section, not // a JSON attribute. - for (String key : new String[] {"rollupGuard", "checks", "labelParts", "relatedEntities", "scopedCalendars", "lookupColumns"}) { + for (String key : new String[] {"rollupGuard", "checks", "labelParts", "relatedEntities", "scopedCalendars", "lookupColumns", + "duplicateReset", "duplicateDefaults"}) { assertTrue(edm.contains(key + "=\""), "the .edm must carry the structured value [" + key + "] as an attribute"); } @@ -298,6 +317,8 @@ void structured_values_survive_the_edm_to_model_round_trip() { assertEntityStructuredEquals(modelFromIntent, modelFromEdm, "Category", "scopedCalendars"); assertEntityStructuredEquals(modelFromIntent, modelFromEdm, "Booking", "checks"); assertEntityStructuredEquals(modelFromIntent, modelFromEdm, "Booking", "rollupGuard"); + assertEntityStructuredEquals(modelFromIntent, modelFromEdm, "Order", "duplicateReset"); + assertEntityStructuredEquals(modelFromIntent, modelFromEdm, "Order", "duplicateDefaults"); assertPropertyStructuredEquals(modelFromIntent, modelFromEdm, "Product", "Category", "lookupColumns"); // uniqueConstraints is owned by the composite-unique-key feature (a section, not a 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 e25d549dd8c..dde866db3e0 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 @@ -700,6 +700,29 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: Roster, kind: manyToOne, to: Roster, composition: true, required: true } - { name: Person, kind: manyToOne, to: Person } + # #7358: the Duplicate cloned the header verbatim, so a copy kept the source's dates. The + # object form of `duplicable` is what the generated document page has to render - one + # delete per reset, one assignment per default, and `now` in the field's own shape. + - name: Reorder + function: Document + duplicable: + defaults: { orderedOn: now, period: now, comment: "Copy" } + reset: [note] + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: reference, type: string, length: 40, function: DocumentTitle } + - { name: orderedOn, type: date, required: true } + - { name: period, type: month } + - { name: note, type: string, length: 100 } + - { name: comment, type: string, length: 100 } + - name: ReorderItem + function: DocumentItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: Reorder, kind: manyToOne, to: Reorder, composition: true, required: true } + # partner: the EXTERNAL-partner mirror of personal - PartnerTicket is owned by a Person # (reusing identity: email; the admin seed maps the IT user), with a sensitive field. - name: PartnerTicket @@ -2659,6 +2682,21 @@ private void assertEmission() { String claimMyFormPage = contentOf("gen/emission/js/components/pages/my/ClaimMyFormPage.js"); assertTrue(claimMyFormPage.contains("readOnly: false"), "a writable child's panel must keep offering its Add"); + // #7358: what a Duplicate does NOT copy. Without the object form every ordinary user field + // rides along, so "same document as last month" opens dated last month - and a + // calculatedActionOnCreate cannot repair it, since it fills an empty value and respects a + // present one. + String reorderDoc = contentOf("gen/emission/js/components/pages/Reorder/ReorderDocumentPage.js"); + assertTrue(reorderDoc.contains("delete header['Note'];"), "a duplicable reset must be dropped from the cloned header"); + assertTrue(reorderDoc.contains("header['OrderedOn'] = this.todayAs('date');"), + "now on a date field must be written as today in that field's shape"); + assertTrue(reorderDoc.contains("header['Period'] = this.todayAs('month');"), + "now on a month field must be the YYYY-MM shape, not a full date"); + assertTrue(reorderDoc.contains("header['Comment'] = \"Copy\";"), "a literal default must reach the page quoted"); + assertTrue(reorderDoc.contains("todayAs(shape)") && reorderDoc.contains("now.getFullYear() + '-' + pad(now.getMonth() + 1)"), + "todayAs must build from the LOCAL calendar fields - toISOString is UTC, so a copy made in the evening" + + " east of Greenwich would be dated yesterday"); + // assignee: personal - the BPMN assigns the task to the start-time-resolved owner and the // trigger listener seeds that variable from the identity mapping. String bpmn = contentOf("ClaimConfirm.bpmn");