Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ public static String pascalCase(String name) {
}

/**
* Camel-/Pascal-case to upper snake. Handles {@code IDValue} -> {@code ID_VALUE}.
* Camel-/Pascal-case to upper snake. Handles {@code IDValue} -> {@code ID_VALUE}, and collapses any
* run of non-alphanumeric separators ({@code -}, space, {@code .}, {@code /}) to a single
* underscore so a kebab-case intent/project name produces a <b>valid SQL identifier</b>:
* {@code sales-invoices} -> {@code SALES_INVOICES}, not the invalid {@code SALES-INVOICES} (an
* unquoted {@code -} is parsed as minus and breaks table creation). Leading/trailing separators do
* not leave a dangling underscore. Pure-identifier input (entity / field names) is unaffected.
*
* @param name the identifier to convert (may be null)
* @return the upper-snake form, empty for null/empty input
Expand All @@ -87,11 +92,23 @@ public static String upperSnake(String name) {
StringBuilder out = new StringBuilder(name.length() + 8);
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) {
if (!Character.isLetterOrDigit(c)) {
// Separator (-, space, ., /, ...): emit a single underscore, never doubled or leading.
if (out.length() > 0 && out.charAt(out.length() - 1) != '_') {
out.append('_');
}
continue;
}
if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1)) && out.length() > 0
&& out.charAt(out.length() - 1) != '_') {
out.append('_');
}
out.append(Character.toUpperCase(c));
}
// A trailing separator would leave a dangling underscore.
if (out.length() > 0 && out.charAt(out.length() - 1) == '_') {
out.setLength(out.length() - 1);
}
return out.toString();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ private static EdmDocument buildDocument(IntentGenerationContext context, Intent
List<Map<String, Object>> entityList = new ArrayList<>();
List<Map<String, Object>> perspectiveList = new ArrayList<>();
String tablePrefix = IntentNaming.upperSnake(intentName);
// Document (header-items) layout: a master that owns a composition child whose name ends in
// "Item" (SalesInvoice -> SalesInvoiceItem) renders as a document - header form, inline items
// table, totals footer - rather than the default master-detail. Maps master -> its items entity.
Map<String, String> documentItems = documentMasters(entities, compositionParents);
int perspectiveOrder = 1;

for (EntityIntent entity : entities) {
Expand All @@ -157,6 +161,13 @@ private static EdmDocument buildDocument(IntentGenerationContext context, Intent
String perspective = perspectiveFor(name, compositionParents, settingEntities);
Map<String, Object> entityMap = entityDefaults(name, entity.getDescription(), entity.getIcon(), dependent, setting, perspective,
tablePrefix, perspectiveOrder);
// A document master keeps its own perspective/nav but swaps the master-detail layout for the
// document layout; it names its line-items entity so the document page renders that child as
// the inline table (and any other composition children as ordinary detail panels).
if (documentItems.containsKey(name)) {
entityMap.put("layoutType", "MANAGE_DOCUMENT");
entityMap.put("documentItemsEntity", documentItems.get(name));
}
// A navigation-group id makes the generated perspective nest under that group in the shared
// application shell (the standalone shell is unaffected). Defaults to empty (top-level).
if (notBlank(entity.getGroup())) {
Expand Down Expand Up @@ -292,6 +303,27 @@ private static Map<String, EntityIntent> indexEntities(List<EntityIntent> entiti
return index;
}

/**
* Document masters: each entity that is the composition parent of a child whose name ends in
* {@code Item} maps to that child (the line-items entity). Iterated in entity-declaration order so
* the first {@code *Item} child wins deterministically when a master has several. Such a master
* renders with the document (header-items) layout instead of master-detail.
*/
private static Map<String, String> documentMasters(List<EntityIntent> entities, Map<String, String> compositionParents) {
Map<String, String> masters = new LinkedHashMap<>();
for (EntityIntent entity : entities) {
String child = entity.getName();
if (child == null || !child.endsWith("Item")) {
continue;
}
String parent = compositionParents.get(child);
if (parent != null && !masters.containsKey(parent)) {
masters.put(parent, child);
}
}
return masters;
}

/**
* Map each entity to its composition parent: the target of its first {@code composition: true}
* {@code manyToOne} / {@code oneToOne} relation. Entities present as keys are DEPENDENT; their
Expand Down Expand Up @@ -456,6 +488,11 @@ private static Map<String, Object> propertyMap(String entityName, FieldIntent fi
p.put("calculatedPropertyExpressionUpdate", field.getCalculatedOnUpdate());
}
}
// Render hint for the document (header-items) layout: show this property in the totals footer
// under the items table rather than in the header form. Presentational only.
if (field.isAggregate()) {
p.put("aggregate", "true");
}
p.put("auditType", "NONE");
p.put("widgetType", widgetForType(dataType));
p.put("widgetSize", "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ public class FieldIntent {
* {@link #calculatedOnCreate}).
*/
private String calculatedOnUpdate;
/**
* Render hint: a document (header-items) layout shows this property in the right-aligned totals
* footer under the items table, not in the header form. Typically a calculated numeric total
* ({@code net} / {@code vat} / {@code total}). Purely presentational - the value is produced by the
* calculated-field expressions ({@link #calculatedOnCreate} / {@link #calculatedOnUpdate}).
*/
private boolean aggregate;

public String getName() {
return name;
Expand Down Expand Up @@ -136,6 +143,14 @@ public boolean isCalculated() {
|| (calculatedOnUpdate != null && !calculatedOnUpdate.isBlank());
}

public boolean isAggregate() {
return aggregate;
}

public void setAggregate(boolean aggregate) {
this.aggregate = aggregate;
}

public String getDefaultValue() {
return defaultValue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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.generator;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class IntentNamingTest {

@Test
void upperSnakeKeepsCamelCaseBoundaries() {
assertEquals("LOANED_ON", IntentNaming.upperSnake("loanedOn"));
assertEquals("ID", IntentNaming.upperSnake("id"));
assertEquals("UO_M", IntentNaming.upperSnake("UoM"));
assertEquals("CUSTOMER", IntentNaming.upperSnake("Customer"));
}

@Test
void upperSnakeCollapsesSeparatorsToUnderscore() {
// A kebab-case intent/project name must become a VALID SQL identifier: SALES_INVOICES, not the
// invalid SALES-INVOICES (an unquoted hyphen is parsed as minus and breaks table creation).
assertEquals("SALES_INVOICES", IntentNaming.upperSnake("sales-invoices"));
assertEquals("CUSTOMER_PAYMENTS", IntentNaming.upperSnake("customer-payments"));
assertEquals("A_B_C", IntentNaming.upperSnake("a.b/c"));
assertEquals("MY_APP", IntentNaming.upperSnake("my app"));
}

@Test
void upperSnakeDoesNotLeaveDanglingOrDoubledUnderscores() {
assertEquals("A_B", IntentNaming.upperSnake("a--b"));
assertEquals("AB", IntentNaming.upperSnake("ab-"));
assertEquals("AB", IntentNaming.upperSnake("-ab"));
assertEquals("", IntentNaming.upperSnake("---"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ void salesInvoiceModelsCrossModelNToMAndCalculatedNumber() {
assertEquals("PRIMARY", invoice.get("type"));
// Settings owned by this model are NOT projections (they generate their own tables here).
assertNull(entityByName(entities, "PaymentMethod").get("projectionReferencedModel"));

// SalesInvoice owns a composition child whose name ends in "Item" -> it renders with the document
// (header-items) layout and names its line-items entity; the totals fields carry the aggregate
// render hint (shown in the footer, not the header form).
assertEquals("MANAGE_DOCUMENT", invoice.get("layoutType"), "a master with an *Item composition child uses the document layout");
assertEquals("SalesInvoiceItem", invoice.get("documentItemsEntity"), "the document names its line-items entity");
assertEquals("true", propertyByName(invoice, "Total").get("aggregate"), "a field marked aggregate carries the footer render hint");
assertEquals("true", propertyByName(invoice, "Net").get("aggregate"));
assertNull(propertyByName(invoice, "Date").get("aggregate"), "a non-aggregate field must not carry the hint");
// The items child itself stays a normal detail (its inline table + controller come from there).
assertEquals("MANAGE_DETAILS", entityByName(entities, "SalesInvoiceItem").get("layoutType"));
}

private static Map<String, Object> buildFromResource(String resource, String intentName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ entities:
- { name: number, type: string, length: 100, calculatedOnCreate: "java.util.UUID.randomUUID().toString()" }
- { name: date, type: date, required: true }
- { name: due, type: date }
- { name: net, type: decimal, precision: 18, scale: 2 }
- { name: vat, type: decimal, precision: 18, scale: 2 }
- { name: gross, type: decimal, precision: 18, scale: 2 }
- { name: discount, type: decimal, precision: 18, scale: 2 }
- { name: total, type: decimal, precision: 18, scale: 2 }
- { name: paid, type: decimal, precision: 18, scale: 2 }
- { name: net, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: vat, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: gross, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: discount, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: total, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: paid, type: decimal, precision: 18, scale: 2, aggregate: true }
- { name: uuid, type: uuid, unique: true }
relations:
- { name: Customer, kind: manyToOne, to: Customer, model: customers, required: true }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Generated by Eclipse Dirigible based on model and template.
*
* Do not modify the content as it may be re-generated again.
*/

/*
* Document (header-items) view type — Harmonia SPA variant.
*
* A master that owns a composition child whose name ends in "Item" (SalesInvoice ->
* SalesInvoiceItem) is emitted by EdmIntentGenerator with layoutType MANAGE_DOCUMENT and a
* `documentItemsEntity` pointing at that child. Such a master renders as a full-page document:
* - a header form (the master's own scalar + relationship fields, minus the aggregate totals),
* - an inline-editable line-items table (the *Item child, per-row server-side create/update/delete),
* - a right-aligned totals footer (the fields flagged `aggregate` — read-only calculated values; the
* header is re-fetched after every item change so server-side calculated fields refresh).
*
* Routing (see ui/shell/index.html.template, MANAGE_DOCUMENT branch):
* /<Master> -> the reused manage list page (browse documents)
* /<Master>/create -> the document editor (create mode)
* /<Master>/{id}/edit -> the document editor (edit mode; items enabled)
*
* The line-items entity stays a MANAGE_DETAILS DEPENDENT (handled by masterDetail.js): its
* registration (App.registerDetail) supplies the inline table's columns + editColumns + controller,
* so the document page never enumerates the child's fields at generation time. Any OTHER composition
* child of the master renders as an ordinary detail panel below the totals.
*
* Collection: uiDocumentModels = layoutType === "MANAGE_DOCUMENT" && type === "PRIMARY".
*/
export function getSources(parameters) {
const collection = "uiDocumentModels";
return [
// Browse list — reuse the manage list page/view (its New/Edit buttons route to /create and
// /{id}/edit, which the shell maps to the document editor for MANAGE_DOCUMENT entities).
{
location: "/template-application-ui-harmonia-java/ui/perspective/manage/list-page.js.template",
action: "generate",
engine: "velocity",
rename: "gen/{{genFolderName}}/js/components/pages/{{perspectiveName}}/{{name}}ManageListPage.js",
collection
},
{
location: "/template-application-ui-harmonia-java/ui/perspective/manage/list-view.html.template",
action: "generate",
engine: "velocity",
rename: "gen/{{genFolderName}}/views/{{perspectiveName}}/{{name}}-manage-list.html",
collection
},
// The document editor — header form + inline items table + totals footer.
{
location: "/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template",
action: "generate",
engine: "velocity",
rename: "gen/{{genFolderName}}/js/components/pages/{{perspectiveName}}/{{name}}DocumentPage.js",
collection
},
{
location: "/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template",
action: "generate",
engine: "velocity",
rename: "gen/{{genFolderName}}/views/{{perspectiveName}}/{{name}}-document.html",
collection
}
];
};
Loading
Loading