diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index d6c651a78e..97b93d41e6 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -425,6 +425,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Standard print templates (`PrintIntentGenerator`, @Order(800), PR #6119).** One `.print` per document (header-items) master — same structural detection as the EDM document layout (composition child named `*Item`), so no flag is needed. The template (document-template DSL, `dirigible-parsers-document`) derives from the model: humanized title + `documentTitle` number subtitle, header ``s (non-PK/non-aggregate fields + to-one relations), `` with type-aware alignment, totals footer with the `total` aggregate emphasized. `.print` is in `INTENT_OWNED_EXTENSIONS` (scrubbed) but has **no `EXTENSION_TO_RECIPE` entry** — it is consumed at publish by `engine-document`'s `PrintTemplateSynchronizer` (seeds CMS `Templates//Print/en/` create-if-absent), not by a code template. Data contract: `{{document.}}` + items rows with `{{}}` — the Harmonia Print button assembles that payload client-side with FK labels resolved. Tests parse-validate the output with the DSL parser (test-scoped `dirigible-parsers-document` dependency); `IntentEngineIT` asserts `Order.print` in the generate pass. +- **Cross-model report dimensions + typed per-column report filters.** `ReportIntentGenerator` joins a **cross-model** relation dimension (`dimensions: [Customer]` where Customer is `model: customers`) against the owning model's real table/PK/label via `CrossModelSupport` (same resolution as the EDM FK; leaf-first, loud failure) — previously the join used this model's intent-prefixed naming and pointed at a non-existent table. The generated report stack gained **server-side per-column conditions**: the report repository (`template-application-dao-java/data/reportFileEntity.java.template`) accepts `conditions: [{column, operator, value}]` validated against the report's own column aliases + types (`FILTER_COLUMNS` from `$columns`) and wraps the query (`SELECT * FROM (QUERY) AS "REPORT_DATA" WHERE "alias" :reportFilter`, typed named parameters); operators EQ/NE/GT/GTE/LT/LTE/LIKE; unknown column/operator → 400 from the controller; `/export` accepts the same conditions (downloads what the table shows). The Harmonia report page embeds generation-time column metadata (`reportColumns: [{key, kind: date|number|boolean|text}]` from `$column.typeTypescript`) and renders a typed filter panel — date range, number range, boolean select, text contains — switching to `POST /search`/`/count` when filters are active. **Date-bucket dimensions**: `month(field)` (sortable YYYYMM integer via `(EXTRACT(YEAR)*100 + EXTRACT(MONTH))`) and `year(field)` group a date for aggregation — e.g. monthly income/VAT (`dimensions: ["month(date)"]`); standard-SQL `EXTRACT`, so H2/PostgreSQL (SQL Server lacks EXTRACT — documented limitation). The YYYYMM integer pairs with the number-range column filters. Covered by `IntentEngineIT.report_file_stack_generates_typed_column_filters` + the `OrdersByMonth` assertions in `assertReport`; the cross-model joins and monthly buckets verified live on the multi-model sample's invoice reports. - **Multi-language data (`multilingual`) + language/file seeds + `languages:`** — the TS-era data-translation feature ported to the Java stack and exposed in the DSL, end-to-end: SDK `Translator` (api-modules-java, name-based `
_LANG` overlay), multilingual finder overrides in `Repository.java.template`, `_LANG` table emission in `application.schema.template`, `EntityIntent.multilingual`/`SeedIntent.language`/`SeedIntent.file`/`IntentModel.languages` with parser validation, `CsvimIntentGenerator` language + file seeds, and the Harmonia Region & Language Settings entry (shared `locale` store + `Accept-Language` in `api.js` + Print default). See the semantics bullet above. Covered by `IntentParserTest`/`EdmIntentGeneratorTest`/`CsvimIntentGeneratorTest`/`IntentEngineIT` (`multilingual_entity_generates_the_translation_stack`). - **Depends-On (`dependsOn`) exposed in the DSL** — cascading dropdowns + auto-populated fields, end-to-end: parser validation (`validateDependsOn`), `EdmIntentGenerator.putDependsOn` emitting the `widgetDependsOn*` EDM attributes (AngularJS stacks consume them as-is), the whole Harmonia runtime (manage form + document header watchers, metadata-driven item-dialog cascade, `draftOptions` separation, Refresh/Add-new filter re-application), `parameterUtils.widgetDependsOnControllerUrl`, and `CrossModelSupport.TargetInfo.propertyNames` for cross-model reference validation. See the semantics bullet above. Covered by `IntentParserTest`/`EdmIntentGeneratorTest`/`IntentEngineIT` (incl. a generated-DocumentPage content assertion); showcased in `dirigiblelabs/sample-intent-multi-model`. - **Form-control `size` + related-field `show` (Harmonia layout DSL), and manual n:m Add/Delete (PR #6117).** Two symmetric authoring attributes on the intent, both flowing through `EdmIntentGenerator` into the `.model` and read by the Harmonia templates: (1) **`size`** on a field OR a to-one relation = the form-control width as a 12-column grid span (1-12, typically 3/4/6/12) → the property's `widgetSize` → `grid-column: span N` (previously `widgetSize` was always empty = half-width, so there was no way to pack short controls onto one row; the parser fail-fast-validates the range). (2) **`show: [field, ...]`** on a to-one relation = target field names to surface as extra **read-only** columns wherever the relation renders as a lookup column (master-detail + document allocation tables) — emitted as `lookupColumns` on the FK property (a List, so it is **skipped from the scalar-only `.edm` XML** via the `Iterable`/`Map` guard in `appendPropertyValue`, and lives only in the `.model` twin); `detail-register.js.template` emits one `via` column per entry, and the shared `detailPanel` now keys its FK-lookup fetch by **FK → the whole referenced row** (not just the label) so a `via` column reads any field off the same already-fetched row — no extra request, and it works for a **cross-model** target (avoiding the raw-vs-sanitized web-folder URL trap: no URL is constructed at all). Both documented in `intent-assistant-guide.md`. Alongside these, the Harmonia **document allocation panels** gained a manual **Add/Delete** corrective override (reusing the shared `detailPanel` `addRow`/`askDelete`/`confirmDelete`; both go through the junction Repository so the create/`-deleted` events fire and the paid/balance/status rollup recomputes — Edit omitted; delete-to-zero keeps the last non-zero status by the rollup's existing `signum > 0` guard, a documented limitation). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java index 5d0703417a..0b43ed58cf 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -22,12 +22,14 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport; import org.eclipse.dirigible.components.intent.generator.IntentNaming; import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; import org.eclipse.dirigible.components.intent.model.EntityIntent; 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.eclipse.dirigible.components.intent.model.UsesIntent; import org.eclipse.dirigible.components.intent.model.ReportIntent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -90,6 +92,11 @@ public class ReportIntentGenerator implements IntentTargetGenerator { private static final Set KNOWN_AGGREGATES = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX"); private static final Pattern DOTTED_REF = Pattern.compile("\\b([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\b"); private static final Pattern SIMPLE_CONDITION = Pattern.compile("^\\s*(\\S+)\\s*(<=|>=|<>|!=|=|<|>)\\s*(.+?)\\s*$"); + /** + * {@code month(field)} / {@code year(field)} dimension - the bucket function in group 1, field in + * group 2. + */ + private static final Pattern DATE_BUCKET = Pattern.compile("\\s*(month|year)\\s*\\(\\s*([^)]+?)\\s*\\)\\s*", Pattern.CASE_INSENSITIVE); @Override public String name() { @@ -139,6 +146,29 @@ private static Map build(IntentGenerationContext context, Report if (dimension == null || dimension.isBlank()) { continue; } + // A month(field)/year(field) dimension buckets a date for aggregation: month emits the + // sortable YYYYMM integer (EXTRACT(YEAR) * 100 + EXTRACT(MONTH) - e.g. 202607), year the + // plain year. EXTRACT is standard SQL (H2, PostgreSQL); SQL Server does not support it - + // date-bucketed reports are an H2/PostgreSQL feature for now. + Matcher bucket = DATE_BUCKET.matcher(dimension.trim()); + if (bucket.matches()) { + String function = bucket.group(1) + .toLowerCase(Locale.ROOT); + String fieldReference = bucket.group(2) + .trim(); + ColumnRef ref = resolve(context, model, source, baseAlias, fieldReference); + registerJoin(joins, ref); + String expression = "month".equals(function) + ? "(EXTRACT(YEAR FROM " + ref.qualified() + ") * 100 + EXTRACT(MONTH FROM " + ref.qualified() + "))" + : "EXTRACT(YEAR FROM " + ref.qualified() + ")"; + String alias = humanize(function + " " + fieldReference.replace('.', ' ')); + columns.add(column(ref.tableAlias, alias, ref.physicalColumn, "INTEGER", "NONE", aggregated)); + selectParts.add(expression + " as \"" + alias + "\""); + if (aggregated) { + groupParts.add(expression); + } + continue; + } ColumnRef ref = resolve(context, model, source, baseAlias, dimension.trim()); registerJoin(joins, ref); columns.add(column(ref.tableAlias, ref.displayAlias, ref.physicalColumn, ref.reportType, "NONE", aggregated)); @@ -224,9 +254,10 @@ private static ColumnRef resolve(IntentGenerationContext context, IntentModel mo ref.tableAlias = targetAlias; ref.physicalColumn = column(targetAlias, fieldName); FieldIntent targetField = fieldByName(target, fieldName); - ref.reportType = reportType(targetField == null ? null : targetField.getType()); + // A cross-model target's fields are not in this model; string is the safe display type. + ref.reportType = targetField == null ? "CHARACTER VARYING" : reportType(targetField.getType()); ref.displayAlias = humanize(reference.replace('.', ' ')); - ref.join = join(context, source, relation, target, targetAlias, baseAlias); + ref.join = join(context, model, source, relation, target, targetAlias, baseAlias); return ref; } } @@ -247,13 +278,15 @@ private static ColumnRef resolve(IntentGenerationContext context, IntentModel mo && ("manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()))) { EntityIntent target = entityByName(model, relation.getTo()); String targetAlias = relation.getTo(); - String labelField = labelFieldName(target); + // A cross-model target's label comes from the resolved owner model (its Name-like field). + CrossModelSupport.TargetInfo info = crossModelInfo(context, model, relation); + String labelField = info != null ? info.labelField() : labelFieldName(target); FieldIntent labeled = fieldByName(target, labelField); ref.tableAlias = targetAlias; ref.physicalColumn = column(targetAlias, labelField); - ref.reportType = reportType(labeled == null ? null : labeled.getType()); + ref.reportType = info != null ? "CHARACTER VARYING" : reportType(labeled == null ? null : labeled.getType()); ref.displayAlias = humanize(reference); - ref.join = join(context, source, relation, target, targetAlias, baseAlias); + ref.join = join(context, model, source, relation, target, targetAlias, baseAlias); return ref; } // Best-effort: treat the reference as a raw column on the source. @@ -293,15 +326,41 @@ private static boolean isTextType(String type) { return "string".equals(t) || "text".equals(t) || "uuid".equals(t); } - private static Join join(IntentGenerationContext context, EntityIntent source, RelationIntent relation, EntityIntent target, - String targetAlias, String baseAlias) { - FieldIntent targetPk = target == null ? null : primaryKeyOf(target); + private static Join join(IntentGenerationContext context, IntentModel model, EntityIntent source, RelationIntent relation, + EntityIntent target, String targetAlias, String baseAlias) { String fkColumn = quote(column(source.getName(), relation.getName())); + // A cross-model target's table and primary-key column come from the resolved owner model - + // this model's intent-prefixed naming would point at a non-existent local table. + CrossModelSupport.TargetInfo info = crossModelInfo(context, model, relation); + if (info != null) { + return new Join(info.tableDataName(), targetAlias, + baseAlias + "." + fkColumn + " = " + targetAlias + "." + quote(info.keyColumn())); + } + FieldIntent targetPk = target == null ? null : primaryKeyOf(target); String pkColumn = quote(column(targetAlias, targetPk == null ? "id" : targetPk.getName())); return new Join(IntentNaming.tableName(context, targetAlias), targetAlias, baseAlias + "." + fkColumn + " = " + targetAlias + "." + pkColumn); } + /** + * The resolved owner-model facts for a cross-model relation, or null for a same-model one. + * Resolution mirrors the EDM generator (workspace, then registry; convention fallback only with a + * null context) and fails loudly for an unresolvable dependency - generate leaf-first. + */ + private static CrossModelSupport.TargetInfo crossModelInfo(IntentGenerationContext context, IntentModel model, + RelationIntent relation) { + if (!relation.isCrossModel()) { + return null; + } + for (UsesIntent uses : model.getUses()) { + if (relation.getModel() + .equals(uses.getModel())) { + return CrossModelSupport.resolve(context, uses, relation.getTo()); + } + } + return null; + } + private static void registerJoin(Map joins, ColumnRef ref) { if (ref.join != null) { joins.putIfAbsent(ref.join.alias, ref.join); @@ -351,7 +410,7 @@ private static String buildWhere(IntentGenerationContext context, IntentModel mo if (relation != null && relation.getTo() != null) { EntityIntent target = entityByName(model, relation.getTo()); String targetAlias = relation.getTo(); - joins.putIfAbsent(targetAlias, join(context, source, relation, target, targetAlias, baseAlias)); + joins.putIfAbsent(targetAlias, join(context, model, source, relation, target, targetAlias, baseAlias)); matcher.appendReplacement(dotted, Matcher.quoteReplacement(targetAlias + "." + quote(column(targetAlias, matcher.group(2))))); } else { @@ -427,6 +486,13 @@ private static Map column(String tableAlias, String alias, Strin column.put("grouping", grouping && "NONE".equals(aggregate)); column.put("tId", translationId(alias)); column.put("label", alias); + // Rendering metadata carried on the model so every report UI aligns and formats consistently: + // numeric columns right-align; decimals carry the platform money pattern. + boolean numeric = "INTEGER".equals(reportType) || "BIGINT".equals(reportType) || "DECIMAL".equals(reportType); + column.put("align", numeric ? "right" : "left"); + if ("DECIMAL".equals(reportType)) { + column.put("pattern", "### ### ### ##0.00"); + } return column; } 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 2aab25ccae..2d5c27feef 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 @@ -397,6 +397,10 @@ reports: ``` **Rules:** `source` is a declared entity. A bare to-one relation dimension shows the target's label, + +A dimension may bucket a date for aggregation: `month(field)` (a sortable YYYYMM integer, e.g. +202607) or `year(field)` — e.g. `dimensions: ["month(date)"]` with `measures: ["sum(total)", "sum(vat)"]` +for monthly income/VAT. (Uses standard-SQL `EXTRACT` — H2/PostgreSQL; not SQL Server.) `relation.field` joins to a related field, `field` is a plain column. ### permissions - roles diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template index be8b3a93af..433c5b2de6 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template @@ -12,15 +12,32 @@ import org.eclipse.dirigible.sdk.utils.Json; * Generated repository for the ${name} report. Executes the report query against the configured data * source and returns the rows as generic maps keyed by the result-set column aliases. * + * Besides the report's own declared parameters, a request may carry per-column {@code conditions} + * ({@code [{column, operator, value}]}) applied OVER the report's output: the query is wrapped as + * {@code SELECT * FROM (QUERY) AS "REPORT_DATA" WHERE "" :reportFilter}. Columns are + * validated against the report's column aliases and typed from the report metadata, operators + * against a fixed whitelist - so the filter surface is exactly the visible table. + * * Do not modify the content as it may be re-generated again. */ public class ${name}Repository { private static final String QUERY = ${queryJava}; + /** The report's output columns (result-set alias -> SQL type) - the filterable surface. */ + private static final Map FILTER_COLUMNS = Map.ofEntries( +#foreach($column in $columns) + Map.entry("${column.alias}", "${column.type}")#if($foreach.hasNext),#end +#end + ); + + private static final Map OPERATORS = + Map.of("EQ", "=", "NE", "<>", "GT", ">", "GTE", ">=", "LT", "<", "LTE", "<=", "LIKE", "LIKE"); + @SuppressWarnings("unchecked") public List> findAll(Integer limit, Integer offset, Map filter) { - StringBuilder sql = new StringBuilder(QUERY); + List> parameters = baseParameters(filter); + StringBuilder sql = new StringBuilder(filteredQuery(filter, parameters)); if (limit != null) { sql.append(" LIMIT ").append(limit.intValue()); } @@ -28,7 +45,7 @@ public class ${name}Repository { sql.append(" OFFSET ").append(offset.intValue()); } try { - String result = Database.queryNamed(sql.toString(), parameters(filter)); + String result = Database.queryNamed(sql.toString(), Json.stringify(parameters)); return (List>) Json.parse(result, List.class); } catch (Throwable t) { throw new RuntimeException("Failed to execute report ${name}", t); @@ -37,9 +54,10 @@ public class ${name}Repository { @SuppressWarnings("unchecked") public long count(Map filter) { - String sql = "SELECT COUNT(*) AS REPORT_COUNT FROM (" + QUERY + ")"; + List> parameters = baseParameters(filter); + String sql = "SELECT COUNT(*) AS REPORT_COUNT FROM (" + filteredQuery(filter, parameters) + ") AS \"REPORT_TOTAL\""; try { - String result = Database.queryNamed(sql, parameters(filter)); + String result = Database.queryNamed(sql, Json.stringify(parameters)); List> rows = (List>) Json.parse(result, List.class); if (rows.isEmpty()) { return 0L; @@ -54,16 +72,71 @@ public class ${name}Repository { } } - public List> exportCsv() { - return findAll(null, null, null); + public List> exportCsv(Map filter) { + return findAll(null, null, filter); + } + + /** + * The report query, wrapped with the request's per-column conditions when present. Each condition + * binds a named parameter typed from the column's report metadata. + */ + private static String filteredQuery(Map filter, List> parameters) { + List> conditions = conditionsOf(filter); + if (conditions.isEmpty()) { + return QUERY; + } + StringBuilder sql = new StringBuilder("SELECT * FROM (").append(QUERY).append(") AS \"REPORT_DATA\" WHERE "); + for (int i = 0; i < conditions.size(); i++) { + Map condition = conditions.get(i); + String column = stringValue(condition.get("column")); + String type = FILTER_COLUMNS.get(column); + if (type == null) { + throw new IllegalArgumentException("Unknown report filter column: " + column); + } + String operator = OPERATORS.get(stringValue(condition.get("operator"))); + if (operator == null) { + throw new IllegalArgumentException("Unknown report filter operator: " + condition.get("operator")); + } + if (i > 0) { + sql.append(" AND "); + } + String marker = "reportFilter" + i; + sql.append("\"").append(column).append("\" ").append(operator).append(" :").append(marker); + parameters.add(parameter(marker, parameterType(type), condition.get("value"))); + } + return sql.toString(); + } + + @SuppressWarnings("unchecked") + private static List> conditionsOf(Map filter) { + Object conditions = filter == null ? null : filter.get("conditions"); + if (conditions instanceof List list) { + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + result.add((Map) map); + } + } + return result; + } + return List.of(); + } + + /** The named-parameter type for a report column type. */ + private static String parameterType(String columnType) { + return "CHARACTER VARYING".equalsIgnoreCase(columnType) ? "VARCHAR" : columnType; + } + + private static String stringValue(Object value) { + return value == null ? null : value.toString(); } - private static String parameters(Map filter) { + private static List> baseParameters(Map filter) { List> parameters = new ArrayList<>(); #foreach($parameter in $parameters) parameters.add(parameter("${parameter.name}", "${parameter.type}", value(filter, "${parameter.name}", "${parameter.initial}"))); #end - return Json.stringify(parameters); + return parameters; } private static Map parameter(String name, String type, Object value) { diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template index 203f9adcda..76b2bf93c4 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template @@ -66,7 +66,11 @@ public class ${name}Controller { #if($needsRoles) checkPermissions("read"); #end - return Map.of("count", repository.count(filter)); + try { + return Map.of("count", repository.count(filter)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } @Post("/search") @@ -75,16 +79,26 @@ public class ${name}Controller { #if($needsRoles) checkPermissions("read"); #end - return repository.findAll(intValue(filter, "$limit"), intValue(filter, "$offset"), filter); + try { + return repository.findAll(intValue(filter, "$limit"), intValue(filter, "$offset"), filter); + } catch (IllegalArgumentException e) { + // An unknown filter column/operator is a client error, not a server failure. + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } @Post("/export") @Documentation("Export ${name}") - public List> exportCsv() { + public List> exportCsv(@Body Map filter) { #if($needsRoles) checkPermissions("read"); #end - return repository.exportCsv(); + try { + // Exports honor the same per-column conditions the table shows. + return repository.exportCsv(filter); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } private static Integer intValue(Map filter, String key) { diff --git a/components/template/template-application-ui-harmonia-java/README.md b/components/template/template-application-ui-harmonia-java/README.md index fb00a5ee29..e46a259a93 100644 --- a/components/template/template-application-ui-harmonia-java/README.md +++ b/components/template/template-application-ui-harmonia-java/README.md @@ -34,7 +34,10 @@ for a single language) writes the shared `locale` Alpine store `codbex.harmonia.language`); the shared fetch client sends the value as `Accept-Language` on every call, which the generated multilingual Java repositories translate by (`
_LANG` overlay), and the document Print flow prefers the same -language when a template for it exists. UI **labels** remain untranslated — the Harmonia +language when a template for it exists. The standalone **report page** offers **typed per-column filters** (date ranges, +number ranges, boolean, text contains) from generation-time column metadata, applied +**server-side** over the wrapped report query — pagination, count and CSV export all +reflect the active filters. UI **labels** remain untranslated — the Harmonia framework itself has no i18n API (verified against 1.24.2: only breakpoint + colour-scheme helpers), so label i18n is a documented follow-up on top of the locale store; the generated `translations/en-US/*.json` catalogs already exist for it. Remaining items are refinements — see the checklist + the diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js index 88f1fb4109..dc9a82763b 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js @@ -24,6 +24,14 @@ export function generate(model, parameters) { if (e.typeTypescript === "Date") { model.hasDates = true; } + // Rendering defaults for .report files that predate the align/pattern column metadata: + // numeric columns right-align, decimals get the platform money pattern. + if (!e.align) { + e.align = e.typeTypescript === 'number' ? 'right' : 'left'; + } + if (!e.pattern && (String(e.type).toUpperCase() === 'DECIMAL' || String(e.type).toUpperCase() === 'DOUBLE')) { + e.pattern = '### ### ### ##0.00'; + } }); model?.parameters?.forEach(e => { const parsedDataType = parameterUtils.parseDataTypes(e.type); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template index 603e69372e..3e57af8735 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template @@ -27,10 +27,60 @@
+ + +
+
+
+ +
+
+ + +
+
+
+
@@ -46,13 +96,13 @@
- + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template index 5b40855de1..5a5a201ff8 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template @@ -36,6 +36,16 @@ document.addEventListener('alpine:init', () => { displayName: '${name}', // humanised in init() — "MembersWithLoansDue" -> "Members With Loans Due" rows: [], columns: [], // derived from the data — the controller returns rows keyed by column alias + // The report's typed output columns (generation-time metadata) — the filterable surface. Each + // entry is { key: , kind: date|number|boolean|text }, driving the per-column + // filter controls (date/number ranges, boolean select, text contains). + reportColumns: [ +#foreach($column in $columns) + { key: '${column.alias}', kind: #if($column.typeTypescript == "Date")'date'#elseif($column.typeTypescript == "number")'number'#elseif($column.typeTypescript == "boolean")'boolean'#{else}'text'#end, align: '${column.align}'#if($column.pattern), pattern: '${column.pattern}'#end }, +#end + ], + showFilters: false, + filters: {}, // column key -> { from, to, min, max, contains, equals } (per kind) state: 'loading', // loading | error | empty | default error: null, page: 1, @@ -53,21 +63,68 @@ document.addEventListener('alpine:init', () => { this.preview = new URLSearchParams(location.search).get('preview') === '1'; } catch (e) { /* no location */ } if (this.preview) this.limit = 5; + for (const col of this.reportColumns) { + this.filters[col.key] = { from: '', to: '', min: null, max: null, contains: '', equals: '' }; + } try { document.title = this.displayName; } catch (e) { /* no document */ } this.load(); }, + // The active per-column conditions in the controller's shape: [{ column, operator, value }]. + // Ranges become GTE/LTE pairs, text becomes a LIKE with wildcards, boolean an EQ. + conditions() { + const conditions = []; + for (const col of this.reportColumns) { + const f = this.filters[col.key]; + if (!f) continue; + if (col.kind === 'date') { + if (f.from) conditions.push({ column: col.key, operator: 'GTE', value: f.from }); + if (f.to) conditions.push({ column: col.key, operator: 'LTE', value: f.to }); + } else if (col.kind === 'number') { + if (f.min !== null && f.min !== '') conditions.push({ column: col.key, operator: 'GTE', value: f.min }); + if (f.max !== null && f.max !== '') conditions.push({ column: col.key, operator: 'LTE', value: f.max }); + } else if (col.kind === 'boolean') { + if (f.equals !== '') conditions.push({ column: col.key, operator: 'EQ', value: f.equals === 'true' }); + } else if (f.contains) { + conditions.push({ column: col.key, operator: 'LIKE', value: '%' + f.contains + '%' }); + } + } + return conditions; + }, + + get activeFilterCount() { return this.conditions().length; }, + + applyFilters() { this.page = 1; this.load(); }, + + clearFilters() { + for (const col of this.reportColumns) { + this.filters[col.key] = { from: '', to: '', min: null, max: null, contains: '', equals: '' }; + } + this.page = 1; + this.load(); + }, + async load() { this.state = 'loading'; this.error = null; try { const offset = (this.page - 1) * this.limit; - const rows = await reportHttp.get(this.apiBase + '?${dollar}limit=' + this.limit + '&${dollar}offset=' + offset); + const conditions = this.conditions(); + let rows; + if (conditions.length) { + rows = await reportHttp.post(this.apiBase + '/search', { ${dollar}limit: this.limit, ${dollar}offset: offset, conditions }); + } else { + rows = await reportHttp.get(this.apiBase + '?${dollar}limit=' + this.limit + '&${dollar}offset=' + offset); + } this.rows = rows || []; - // Columns come from the row keys (the report's result-set aliases, e.g. "Member Name"). + // Columns come from the row keys (the report's result-set aliases, e.g. "Member Name"), + // falling back to the generation-time metadata when a filter empties the page. if (this.rows.length) this.columns = Object.keys(this.rows[0]); + else if (!this.columns.length && this.reportColumns.length) this.columns = this.reportColumns.map(c => c.key); try { - const c = await reportHttp.get(this.apiBase + '/count'); + const c = conditions.length + ? await reportHttp.post(this.apiBase + '/count', { conditions }) + : await reportHttp.get(this.apiBase + '/count'); this.count = (c && c.count != null) ? c.count : this.rows.length; } catch (e) { this.count = this.rows.length; } this.state = this.rows.length ? 'default' : 'empty'; @@ -78,6 +135,42 @@ document.addEventListener('alpine:init', () => { this.refreshIcons(); }, + columnMeta(key) { return this.reportColumns.find(c => c.key === key) || null; }, + + // Numeric columns right-align (from the .report column metadata). + alignClass(key) { + const meta = this.columnMeta(key); + return meta && meta.align === 'right' ? 'text-right' : ''; + }, + + // Cell text: decimals format by the column's DecimalFormat-style pattern (grouped thousands, + // fixed decimals - the platform money pattern); everything else renders as-is. + cellText(key, row) { + const v = row[key]; + const meta = this.columnMeta(key); + if (meta && meta.pattern) return this.displayNumber(v, meta.pattern); + // Pattern-less numeric columns (counts, year/month buckets) still render as clean integers - + // the DB returns them as decimals (1.0, 202607.0). + if (meta && meta.kind === 'number') return this.displayNumber(v, '##0'); + return v; + }, + + displayNumber(v, pattern) { + if (v === null || v === undefined || v === '') return ''; + const n = Number(v); + if (isNaN(n)) return v; + let decimals = 2; + let groupSep = ' '; + if (pattern) { + const dot = pattern.lastIndexOf('.'); + decimals = dot >= 0 ? (pattern.length - dot - 1) : 0; + groupSep = pattern.indexOf(' ') >= 0 ? ' ' : (pattern.indexOf(',') >= 0 ? ',' : ''); + } + const parts = n.toFixed(decimals).split('.'); + if (groupSep) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + return parts.length > 1 ? parts[0] + '.' + parts[1] : parts[0]; + }, + get totalPages() { return Math.max(1, Math.ceil(this.count / this.limit)); }, next() { if (this.page < this.totalPages) { this.page++; this.load(); } }, prev() { if (this.page > 1) { this.page--; this.load(); } }, @@ -85,7 +178,8 @@ document.addEventListener('alpine:init', () => { // Export the FULL result set (POST /export returns all rows) as CSV. async exportCsv() { try { - const data = await reportHttp.post(this.apiBase + '/export', {}); + // The export honors the active per-column filters - it downloads what the table shows. + const data = await reportHttp.post(this.apiBase + '/export', { conditions: this.conditions() }); const rows = Array.isArray(data) ? data : []; if (!rows.length) return; const cols = Object.keys(rows[0]); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template index 15781d9348..5cb70250a5 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template @@ -29,6 +29,24 @@ document.addEventListener('alpine:init', () => { this.refreshIcons(); }, + // Format a float using a DecimalFormat-style pattern (decimals from the part after '.', grouping + // from the pattern's separator) - same formatter the document totals use. + displayNumber(v, pattern) { + if (v === null || v === undefined || v === '') return ''; + const n = Number(v); + if (isNaN(n)) return v; + let decimals = 2; + let groupSep = ' '; + if (pattern) { + const dot = pattern.lastIndexOf('.'); + decimals = dot >= 0 ? (pattern.length - dot - 1) : 0; + groupSep = pattern.indexOf(' ') >= 0 ? ' ' : (pattern.indexOf(',') >= 0 ? ',' : ''); + } + const parts = n.toFixed(decimals).split('.'); + if (groupSep) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + return parts.length > 1 ? parts[0] + '.' + parts[1] : parts[0]; + }, + async exportCsv() { try { const data = await App.services.api.post(this.apiPath + '/export', {}); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template index 2749ad8b79..6bf727e2f3 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template @@ -21,7 +21,7 @@ #foreach($property in $properties) - + #end @@ -29,7 +29,13 @@ diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 86379613b0..d51f6722f8 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -134,6 +134,11 @@ class IntentEngineIT extends IntegrationTest { source: Order dimensions: [customer] measures: ["count(*)", "sum(total)"] + # month(field) buckets a date dimension into a sortable YYYYMM integer. + - name: OrdersByMonth + source: Order + dimensions: ["month(orderDate)"] + measures: ["count(*)", "sum(total)"] - name: BigOrderItems source: OrderItem description: Order items with quantity over one, with their order date @@ -219,7 +224,7 @@ void parse_returns_the_full_model() { .body("processes", hasSize(1)) .body("processes[0].steps", hasSize(6)) .body("forms", hasSize(1)) - .body("reports", hasSize(2)) + .body("reports", hasSize(3)) .body("permissions", hasSize(2)) .body("seeds[0].rows", hasSize(2))); } @@ -1023,6 +1028,54 @@ void multilingual_entity_generates_the_translation_stack() { assertTrue(config.contains("languages: [\"en\",\"bg\"]"), "config.js should carry the app's data languages"); } + @Test + void report_file_stack_generates_typed_column_filters() { + writeIntent(INTENT_YAML); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + // Replay the Harmonia report-file template like the editor does. The generation service + // derives the gen folder from the report file name (each report owns gen/). + String payload = "{\"template\":\"template-application-ui-harmonia-java/template/template-report-file.js\",\"parameters\":{}}"; + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(payload) + .when() + .post("/services/js/service-generate/generate.mjs/model/" + WORKSPACE + "/" + PROJECT + + "?path=OrdersByCustomer.report") + .then() + .statusCode(201)); + + // Backend: the report repository validates and applies per-column conditions over the wrapped + // query, typed from the report's own column metadata. + String repository = contentOf("gen/ordersbycustomer/data/reports/OrdersByCustomerRepository.java"); + assertTrue(repository.contains("FILTER_COLUMNS"), "the report repository should carry the filterable-column allowlist"); + assertTrue(repository.contains("SELECT * FROM (\").append(QUERY).append(\") AS \\\"REPORT_DATA\\\" WHERE"), + "conditions should wrap the report query"); + assertTrue(repository.contains("\"GTE\", \">=\""), "range operators should be whitelisted"); + String controller = contentOf("gen/ordersbycustomer/api/reports/OrdersByCustomerController.java"); + assertTrue(controller.contains("exportCsv(@Body Map filter)"), "export should honor the active filters"); + + // Frontend: the generated report page carries typed column metadata and the filter machinery. + // NB the case split: the UI files use the RAW genFolderName (the report file name, + // "OrdersByCustomer"), while the Java files use the sanitized javaGenFolderName + // ("ordersbycustomer") - two distinct folders on a case-sensitive filesystem. + String page = contentOf("gen/OrdersByCustomer/reports/OrdersByCustomer/report.js"); + assertTrue(page.contains("reportColumns"), "the report page should embed the typed column metadata"); + assertTrue(page.contains("{ key: 'Customer', kind: 'text', align: 'left' }"), + "the joined dimension should be a left-aligned text column"); + assertTrue(page.contains("kind: 'number'"), "the aggregate measures should be number columns"); + assertTrue(page.contains("operator: 'GTE'") && page.contains("operator: 'LIKE'"), + "the page should build range and contains conditions"); + String view = contentOf("gen/OrdersByCustomer/reports/OrdersByCustomer/index.html"); + assertTrue(view.contains("applyFilters()") && view.contains("data-lucide=\"filter\""), + "the report view should carry the filter panel and toolbar toggle"); + assertTrue(view.contains("alignClass(col)") && view.contains("cellText(col, row)"), + "the report table should align and format cells from the column metadata"); + assertTrue(page.contains("align: 'right', pattern: '### ### ### ##0.00'"), + "the page metadata should carry alignment + the money pattern for decimal columns"); + } + @Test void regeneration_scrubs_stale_model_files() { writeIntent(INTENT_YAML); @@ -1453,6 +1506,19 @@ private void assertReport() { assertTrue(body.contains("\"table\": \"ORDERS_ORDER\""), "report table should be the same intent-prefixed table name the EDM declares as dataName"); assertTrue(body.contains("\"aggregate\": \"COUNT\""), "count(*) should be parsed into an aggregate COUNT column"); + + // month(field) buckets the date dimension into a sortable YYYYMM integer, grouped the same way. + String monthly = contentOf("OrdersByMonth.report"); + assertTrue( + monthly.contains( + "(EXTRACT(YEAR FROM Order.\\\"ORDER_ORDER_DATE\\\") * 100 + EXTRACT(MONTH FROM Order.\\\"ORDER_ORDER_DATE\\\"))"), + "a month(field) dimension should emit the YYYYMM EXTRACT expression"); + assertTrue(monthly.contains("as \\\"Month Order Date\\\""), "the bucketed column should carry a humanized alias"); + assertTrue(monthly.contains("GROUP BY (EXTRACT(YEAR"), "the aggregation should group by the bucket expression"); + + // Rendering metadata on the model: numeric columns right-align, decimals carry the money pattern. + assertTrue(body.contains("\"align\": \"right\""), "numeric report columns should carry align: right"); + assertTrue(body.contains("\"pattern\": \"### ### ### ##0.00\""), "decimal report columns should carry the money pattern"); assertTrue(body.contains("\"aggregate\": \"SUM\""), "sum(total) should be parsed into an aggregate SUM column"); // The query is materialised SQL (not left empty): SELECT ... FROM
#if($property.widgetLabel)${property.widgetLabel}#else${property.name}#end#if($property.widgetLabel)${property.widgetLabel}#else${property.name}#end
as ... GROUP BY. // Physical table/column identifiers are double-quoted so the SQL runs on PostgreSQL (which folds