diff --git a/.claude/docs/intent-dsl-features.md b/.claude/docs/intent-dsl-features.md index 7e1d895476c..00a4ceaa459 100644 --- a/.claude/docs/intent-dsl-features.md +++ b/.claude/docs/intent-dsl-features.md @@ -20,7 +20,7 @@ Loaded on demand, not on every session: read this before changing a DSL construc **Expand/contract on a live table (`renamedFrom:` / `dropped:`, [#7635](https://github.com/eclipse-dirigible/dirigible/issues/7635)):** a publish no longer drops a column its definition stops declaring - `TableAlterProcessor` keeps it with its data in every tenant, WARNs naming the owning artefact, relaxes a kept `NOT NULL` (dialect `dropNotNull`, `null` on MySQL/MariaDB/MSSQL/HANA, where it would need the whole type: then only the WARN), and counts it as the `orphanColumns` detail of the `artefacts` health component (`PlatformReadiness.recordOrphanColumns`, per catalog.schema.table so tenants do not overwrite each other). A field's `renamedFrom: ` becomes the `.model` property's `dataRenamedFrom` (the old COLUMN, derived like `dataName`) and the schema column's `renamedFrom`, and the alter renames the live column in place before the ADD pass (dialect `renameColumn`; on MSSQL/MongoDB it returns `null` and the ADD pass adds the column nullable, copies the values and keeps the old one); an entity's `dropped: [names]` becomes `dataDropped` (comma-separated - it must survive the scalar-only `.edm`) and the table's `dropped`, the only way besides the dev opt-in `DIRIGIBLE_DATABASE_DROP_UNDECLARED_COLUMNS=true` that a column leaves. Both act only while there is something to do, so they are harmless to leave in the intent. The parser refuses either one naming a column the entity still declares (compared by column, so a relation counts), a rename from its own name or from a dropped name, and two renames from one name. Unique constraints are still reconciled by drop - they hold no data. -**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. +**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. **A to-one relation takes the same two keys ([#7650](https://github.com/eclipse-dirigible/dirigible/issues/7650)):** the caption of a picker is the same problem one construct over - the relation carries the model's identifier, so `Year.company` rendered as "Company" with no way to say "Issuing company" in the intent, and the English caption had to be hand-edited into a catalog the next Generate overwrites. The keys are emitted onto the relation's own FK property, which is where every surface already reads its label, so nothing downstream changed; a `oneToMany` is refused, having no control of its own to caption. **Naming a rendered document (`fileName:`, [#6899](https://github.com/eclipse-dirigible/dirigible/issues/6899)):** the two server-side PDF renders — the snapshot copy a document mints on issue and the `attach: print` PDF a `notify` block sends — accept a **`fileName:`** pattern on the `function: Snapshot` child / inside the notify block: literals plus `{token}` interpolations, no expression language. `{field}` and one-hop `{relation.field}` use the same path vocabulary and **authored names** a notify subject does, `{field:pattern}` formats a `date`/`timestamp` through a `DateTimeFormatter` pattern, `{A|B}` renders the first non-blank operand, `{Version}` (snapshot only) places the copy's version and a pattern without it gets `_v` appended. Interpolated **values** are sanitized at run time by `sdk.print.FileNames` (trim, whitespace → one `_`, path/control characters stripped, non-ASCII deliberately kept); the literal separators are the author's. A pattern's relation loads are **merged into the notify block's own**, deduped by local, so a name may read a relation the message never mentions — but `attach: recordPrint` renders the anchor once, before the per-row loop, so only fields of the anchor are readable there. Absent a pattern, the snapshot default **changes** from the old primary-key form (`Order 42 v1.pdf`) to the same number-or-id expression the mail already used, plus the version — the deliberate fix for the two names disagreeing. An unresolvable path, an unbalanced brace, a format on a non-date field or a pattern that interpolates nothing are validation errors. Details in the engine-intent guide's fileName bullet. 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 94cce02160f..3e2b5a892b3 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 @@ -1342,10 +1342,34 @@ private static List> applyOrder(List> pr * @param field the authored field * @return the variants by country code, empty when none are declared */ + /** + * The authored caption of the control a RELATION renders (#7650) - the picker, its list column and + * its details row - plus its country variants, written exactly as a field's are (#6424) so every + * generated surface and the en-US catalog read one attribute whatever the property came from. + * Absent, the pipeline derives the humanized relation name as before. + * + * @param property the property map being built + * @param relation the relation it was built from + */ + private static void putRelationLabel(Map property, RelationIntent relation) { + if (notBlank(relation.getLabel())) { + property.put("widgetLabel", relation.getLabel() + .trim()); + } + Map variants = countryLabels(relation.getCountryLabels()); + if (!variants.isEmpty()) { + property.put("widgetCountryLabels", variants); + } + } + private static Map countryLabels(FieldIntent field) { + return countryLabels(field.getCountryLabels()); + } + + /** The canonical country-variant map - keys upper-cased, blank variants dropped. */ + private static Map countryLabels(Map authored) { Map canonical = new LinkedHashMap<>(); - for (Map.Entry variant : field.getCountryLabels() - .entrySet()) { + for (Map.Entry variant : (authored == null ? Map.of() : authored).entrySet()) { if (variant.getKey() == null || !notBlank(variant.getValue())) { continue; } @@ -1685,6 +1709,7 @@ private static Map relationProperty(String ownerEntity, Relation boolean oneToOne = "oneToOne".equals(relation.getKind()); Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", column); @@ -1767,6 +1792,7 @@ private static Map subsetProperty(String ownerEntity, RelationIn String targetPerspective) { Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", IntentNaming.upperSnake(ownerEntity) + "_" + IntentNaming.upperSnake(relation.getName())); @@ -1809,6 +1835,7 @@ private static Map crossModelRelationProperty(String ownerEntity boolean oneToOne = "oneToOne".equals(relation.getKind()); Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", column); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java index 5d50bedc24e..164bc75c807 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java @@ -76,6 +76,27 @@ public class RelationIntent { * {@code grid-column: span N}. Absent (the default) leaves it unset (the form falls back to half * width). Use a small span (e.g. 4) to pack several short dropdowns onto one row. */ + /** + * Optional display label for the PICKER this relation renders as, replacing the humanized relation + * name in every generated surface - the form control's caption, the list column header and the + * details row - and seeding its en-US catalog entry (issue #7650). + * + *

+ * The same key a field carries (#6424), for the same reason: the humanized name is derived from an + * identifier the author chose for the MODEL, and a picklist relation that cannot be called after + * the thing it picks (because a stored column already owns that name) renders as the identifier - + * {@code VatGroundPick} as "Vat Ground Pick". The generated en-US catalog is rewritten on every + * Generate, so there is nowhere else to author the English caption. + */ + private String label; + + /** + * Optional label variants keyed by ISO 3166-1 alpha-2 country code (issue #7650), exactly as + * on a field (#6424): what a picker is called may follow the tenant's country rather than the + * reader's language, so the variant overrides {@link #label} in EVERY language. + */ + private java.util.Map countryLabels = new java.util.LinkedHashMap<>(); + private Integer size; /** * Whether this to-one relation shows as a column in the entity's list / document-items table @@ -320,6 +341,22 @@ public void setSize(Integer size) { this.size = size; } + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public java.util.Map getCountryLabels() { + return countryLabels; + } + + public void setCountryLabels(java.util.Map countryLabels) { + this.countryLabels = countryLabels == null ? new java.util.LinkedHashMap<>() : countryLabels; + } + public boolean isMajor() { return major; } 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 a8afee2d91c..b47573ef574 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 @@ -4337,6 +4337,11 @@ private static Set validateEntities(IntentModel model, Set usesA // checks below (composition, cross-model, dependsOn, leafOnly, personal/partner). if ("subset".equals(relation.getKind())) { validateSubset(entity, relation, entityNames, byName, issues); + if (relation.getLabel() != null || !relation.getCountryLabels() + .isEmpty()) { + validateLabels("entity [" + entity.getName() + "] relation [" + relation.getName() + "]", relation.getLabel(), + relation.getCountryLabels(), issues); + } continue; } // ManyToManyExpander consumed every n:m before this ran, so a surviving manyToMany is one @@ -4403,6 +4408,19 @@ private static Set validateEntities(IntentModel model, Set usesA validateDependsOn(entity, subject, relation.getDependsOn(), relation, byName, issues); } } + // The picker's caption (#7650) - a to-one FK property or a subset's multiselect column. + // A collection relation emits no property at all (the FK lives on the child), so a label + // authored there would be carried nowhere: refused rather than silently dropped. + if (relation.getLabel() != null || !relation.getCountryLabels() + .isEmpty()) { + String labelSubject = "entity [" + entity.getName() + "] relation [" + relation.getName() + "]"; + if ("oneToMany".equals(relation.getKind())) { + issues.add(labelSubject + " declares a `label`, but a collection relation renders no control of its own -" + + " label the field or relation the generated page actually shows"); + } else { + validateLabels(labelSubject, relation.getLabel(), relation.getCountryLabels(), issues); + } + } if (relation.getWhere() != null) { validateWhere(entity, relation, byName, issues); } @@ -6887,12 +6905,21 @@ private static void validateFormat(String subject, FieldIntent field, List issues) { - if (field.getLabel() != null && field.getLabel() - .isBlank()) { - issues.add(subject + " declares a blank `label` - remove it to keep the humanized field name"); + validateLabels(subject, field.getLabel(), field.getCountryLabels(), issues); + } + + /** + * The same rule for a RELATION's picker caption (#7650): a relation renders a control of its own - + * the picker, its list column and its details row - and the humanized relation name is as wrong + * there as a humanized field name is, for the same reason (the identifier was chosen for the model, + * and a picklist that cannot be named after what it picks reads as the identifier). + */ + private static void validateLabels(String subject, String label, java.util.Map countryLabels, List issues) { + if (label != null && label.isBlank()) { + issues.add(subject + " declares a blank `label` - remove it to keep the humanized name"); } - for (java.util.Map.Entry variant : field.getCountryLabels() - .entrySet()) { + for (java.util.Map.Entry variant : (countryLabels == null ? java.util.Map.of() + : countryLabels).entrySet()) { String country = variant.getKey() == null ? "" : variant.getKey() .trim() diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java index d9904f36f8d..44d2db19266 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java @@ -268,6 +268,10 @@ private static EntityIntent linkEntity(String linkName, EntityIntent owner, Rela toTarget.setSize(relation.getSize()); toTarget.setMajor(relation.isMajor()); toTarget.setLeafOnly(relation.isLeafOnly()); + // The caption belongs to the control the author sees, which after the expansion is the LINK's + // target picker - the declaring side becomes a navigation-only collection that renders none. + toTarget.setLabel(relation.getLabel()); + toTarget.setCountryLabels(relation.getCountryLabels()); link.getRelations() .add(toOwner); link.getRelations() @@ -299,6 +303,8 @@ private static void rewriteToNavigation(RelationIntent relation, String linkName relation.setShow(null); relation.setSize(null); relation.setLeafOnly(false); + relation.setLabel(null); + relation.setCountryLabels(null); } private static boolean isBlank(String value) { 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 e9724db1162..f24c969ec80 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 @@ -455,6 +455,13 @@ field may declare: still resolve. Not allowed on a composition parent (preset, never picked) or an `EntityStatus`. Canonical shape - a stock line's Product picker excluding services: `- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }` +- `label: ` / `countryLabels: { : }` (on a to-one relation or a + `subset`, #7650) - **the picker's caption**, exactly as the field keys above: the relation is named + for the model, so the chooser, its list column and its details row all read as that identifier + until it is labelled. It rides on the relation's own FK property, so it is translated and + country-resolved like any field label. A collection relation (`oneToMany`) renders no control of + its own and is refused - label the field or relation the generated page actually shows. + `- { name: issuer, kind: manyToOne, to: Company, label: Issuing company }` - `pickable: { when: [ != null, ...], else: mark|hide, message: }` on a manyToOne/oneToOne (#7496) - **a picker rule over the TARGET's rows**: this is how "a customer with incomplete registration data cannot be picked onto an invoice" is declared, so the clerk diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java index d317d398858..c1aed378e70 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java @@ -77,14 +77,59 @@ void theVariantsStayOutOfTheEdmXmlWhileTheLabelDoesNot() { assertFalse(xml.contains("widgetCountryLabels"), "a structured value is never a property attribute"); } + /** + * A to-one relation's caption rides on the FK property the picker is rendered from (#7650), which + * is the same property the catalog key and every surface already read their label off. + */ + private static final String INVOICING = """ + name: invoicing + entities: + - name: Company + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - name: issuer + kind: manyToOne + to: Company + label: Issuing company + countryLabels: + bg: Издател + - { name: payer, kind: manyToOne, to: Company } + """; + + @Test + @SuppressWarnings("unchecked") + void aRelationsLabelReachesItsForeignKeyProperty() { + Map property = property(INVOICING, "invoicing", "Issuer"); + + assertEquals("Issuing company", property.get("widgetLabel")); + assertEquals("Издател", ((Map) property.get("widgetCountryLabels")).get("BG"), + "the runtime compares against the configured country in upper case"); + } + + @Test + void aRelationWithoutALabelCarriesNoneAtAll() { + assertNull(property(INVOICING, "invoicing", "Payer").get("widgetLabel"), + "an unlabelled relation must render exactly as it did before"); + assertNull(property(INVOICING, "invoicing", "Payer").get("widgetCountryLabels")); + } + @SuppressWarnings("unchecked") private static Map variants(String yaml, String propertyName) { return (Map) property(yaml, propertyName).get("widgetCountryLabels"); } - @SuppressWarnings("unchecked") private static Map property(String yaml, String propertyName) { - Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "payroll"); + return property(yaml, "payroll", propertyName); + } + + @SuppressWarnings("unchecked") + private static Map property(String yaml, String module, String propertyName) { + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), module); List> entities = (List>) ((Map) model.get("model")).get("entities"); return entities.stream() .flatMap(entity -> ((List>) entity.get("properties")).stream()) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java index d0621cc12ba..71394eab25f 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java @@ -17,6 +17,7 @@ import java.util.Map; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.junit.jupiter.api.Test; /** @@ -41,6 +42,36 @@ class FieldLabelIntentTest { - { name: name, type: string } """; + /** + * A picker's caption is the same problem one construct over (#7650): the relation is named for the + * model, and the generated chooser, its list column and its details row all read as that identifier + * until the relation itself is labelled. + */ + private static final String INVOICING = """ + name: invoicing + entities: + - name: Company + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - name: issuer + kind: manyToOne + to: Company + label: Issuing company + countryLabels: + BG: Издател + - { name: lines, kind: oneToMany, to: InvoiceLine } + - name: InvoiceLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: invoice, kind: manyToOne, to: Invoice, composition: true } + """; + @Test void aFieldCarriesItsAuthoredLabel() { FieldIntent field = field(IntentParser.parse(PAYROLL), "nationalId"); @@ -108,6 +139,49 @@ void aBlankLabelIsRejected() { assertIssue(PAYROLL.replace("label: National ID", "label: \" \""), "declares a blank `label`"); } + @Test + void aToOneRelationCarriesItsAuthoredPickerCaption() { + RelationIntent relation = relation(IntentParser.parse(INVOICING), "issuer"); + + assertEquals("Issuing company", relation.getLabel()); + assertEquals("Издател", relation.getCountryLabels() + .get("BG")); + } + + @Test + void aRelationThatDeclaresNeitherIsUnaffected() { + RelationIntent relation = relation(IntentParser.parse(INVOICING), "lines"); + + assertNull(relation.getLabel()); + assertTrue(relation.getCountryLabels() + .isEmpty(), + "absent variants must be an empty map, not a null the generators trip over"); + } + + /** A collection renders no control of its own, so a caption authored there would go nowhere. */ + @Test + void aLabelOnACollectionRelationIsRejected() { + assertIssue( + INVOICING.replace(" - { name: lines, kind: oneToMany, to: InvoiceLine }", + " - { name: lines, kind: oneToMany, to: InvoiceLine, label: Line items }"), + "a collection relation renders no control of its own"); + } + + @Test + void aRelationVariantIsHeldToTheSameCountryRule() { + assertIssue(INVOICING.replace(" BG: Издател", " bulgaria: Издател"), "which is not an ISO 3166-1 alpha-2 country code"); + } + + private static RelationIntent relation(IntentModel model, String name) { + return model.getEntities() + .stream() + .flatMap(entity -> entity.getRelations() + .stream()) + .filter(relation -> name.equals(relation.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("no relation [" + name + "]")); + } + private static void assertIssue(String yaml, String expected) { IntentValidationException exception = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));