diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 64f274db5c..219392fc3d 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -265,6 +265,8 @@ Semantics worth knowing: - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events/Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessId` (so it starts at most once). The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events` is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in `service-generate/template/generateUtils.js`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in `generateUtils.js` supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `parameterUtils.js` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). - **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` declares which languages this module PROVIDES translations for (landing on the `.model` root → Harmonia `config.js` `languages`) — it never defines what the stack supports: the **Region & Language** picker always offers the PLATFORM's set (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`, served by `platform-core/services/application-languages.js`), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. The application shell compares each app's provided set against the platform set and lists gaps as warnings in Settings; untranslated content falls back to the default language. Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. +- **`reports[].widget` = a dashboard KPI tile backed by the report.** The report supplies the data (source/dimensions/measures/filter → the generated SQL + controller); the widget only says which number the tile shows: `kind: count` (default — the report's record count via the controller's count endpoint), `kind: value` (`value:` names a declared measure; `at: { : now | }` pins dimension columns as typed EQ conditions over the report output — the `now` token stays symbolic in the `.report` and is resolved client-side, type-aware: `month(x)` → current YYYYMM, `year(x)` → current year, date → today), or `kind: list` (`limit:` rows, default 5, rendered as a mini table from the report's own column metadata). `IntentParser.validateReportWidget` checks kind/value-measure/at-dimensions; `ReportIntentGenerator.widget(...)` resolves authored expressions to column aliases and emits the `widget` block on the `.report` (no SQL, no URLs — path-agnostic rule intact); `EdmIntentGenerator` flips the `.model` root flag `dashboardKpis` so the Harmonia shell template bakes the entity list empty (declared KPIs REPLACE the auto per-entity count tiles; no widgets → unchanged). At runtime the shared reports store (`application-core/shell/js/stores/reports.js`) reads the block off the `.report`, derives the report controller URL from the discovered page path (a `sanitizeJavaIdentifier` mirror — keep it in sync with `parameterUtils.js`), resolves the pins and fetches count/value/rows; a 403 hides the tile (role-guarded report) instead of erroring. A widget-bearing report shows the KPI tile INSTEAD of its iframe preview tile; `dashboard: false` hides both. +- **Top-level `widgets:` = custom dashboard widgets (the dashboard's escape hatch).** `kind: kpi` (default) is a number tile fed by a developer REST endpoint (`url` returns `{value, description?}` — typically a client-Java `@Controller` under `custom/`); `kind: page` embeds the developer's HTML page like a report preview tile. The kind implies how the URL is consumed — there is deliberately no separate source-type field. The parser (`validateWidgets`) checks name/kind and that `url` is a same-origin path (no scheme/host); `EdmIntentGenerator.buildCustomWidgets` bakes them onto the `.model` root (`widgets` array with defaults + `tId`), the model's translate action emits their labels into the catalog, and the Harmonia `dashboardPage.js.template` bakes and renders them (kpi tiles fetch via the shared client with `{ baseUrl: '' }`; page tiles iframe). Custom widgets count toward the `dashboardKpis` flag, so they also replace the auto entity-count tiles. Prefer `reports[].widget` when a report can supply the number; the value the endpoint returns may be a string (`"99.9%"`), rendered as-is. The `.report` widget block is also **authorable by hand in the Web IDE's Report Editor** (`editor-report`: a "Dashboard Widget" panel — enable, kind, label/icon, value-measure picker over the aggregate columns, `at` pins over the grouping columns, list limit — plus Description + "Show on the home dashboard" in General), so classic non-intent projects get KPI tiles too. - **The YAML `name:` field is the intent's identity for outputs.** `IntentNaming.baseName` prefers it over the artefact name derived from the file name (which is conventionally just `app` from `app.intent`); single-file outputs are `.edm` / `.model` / `.roles` and the table prefix is its upper-snake. - **Physical table names are intent-prefixed**: `_` upper-snake (`ORDERS_ORDER`), via `IntentNaming.tableName`, consistently across `.edm` `dataName`, `.report` `table` and `.csvim` `table`. This avoids SQL reserved words (`ORDER`, `USER`, ...) and cross-project collisions in a shared schema. If the downstream "Generate from EDM" wizard asks for a table prefix, intent projects must leave it empty - the prefix is already part of `dataName`. @@ -423,6 +425,8 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / **Done:** +- **Dashboard widgets: report-attached KPIs (`reports[].widget`) + custom widgets (top-level `widgets:`) + Report Editor authoring.** Meaningful KPI tiles ("Overdue Invoices", "Revenue this month") declared on reports instead of the raw per-entity count tiles — see the `reports[].widget` and `widgets:` semantics bullets above for the full contracts. New `WidgetIntent` (kind count/value/list, value/at/label/icon/limit) + `validateReportWidget`; `ReportIntentGenerator` resolves value→`valueColumn`/`valueType`/`pattern` and `at` pins→column aliases (+`bucket`/`token`) into a `widget` block on the `.report`; `CustomWidgetIntent` + `validateWidgets` + `EdmIntentGenerator.buildCustomWidgets` for the REST-kpi/page escape hatch on the `.model` root; `EdmIntentGenerator` sets `.model` `dashboardKpis`; the Harmonia shell template suppresses entity-tile baking on that flag and renders the tiles (count/value number tiles, list mini-tables, custom kpi/page tiles) from the shared reports store, which gained `loadWidgetValue` (apiBase derivation from the page path, client-side `now` resolution, 403→hidden) — plus report/widget label i18n end-to-end (report-template translate action + `-report` catalogs + `displayLabel`/`widgetLabel` store methods used by the sidebar/dashboard/breadcrumb). The Web IDE `editor-report` gained the "Dashboard Widget" panel + General Description/dashboard fields. Covered by `IntentParserTest`/`ReportIntentGeneratorTest`/`EdmIntentGeneratorTest` + `IntentEngineIT` (`report_widget_generates_the_kpi_block_and_replaces_entity_tiles`); verified live on the `kpidemo` project (count=2 overdue, month-pinned sum, top-3 list over the generated report controllers). + - **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. 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 a32316c290..9ab14554e5 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 @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -26,6 +27,7 @@ import org.eclipse.dirigible.components.intent.generator.IntentSettings; import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; import org.eclipse.dirigible.components.intent.generator.TriggerSupport; +import org.eclipse.dirigible.components.intent.model.CustomWidgetIntent; import org.eclipse.dirigible.components.intent.model.DependsOnIntent; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; @@ -308,6 +310,23 @@ else if (!dependent && !setting && !compositionParents.containsValue(name)) { .isEmpty()) { body.put("languages", new ArrayList<>(model.getLanguages())); } + // Any declared dashboard widget - a report-attached KPI or a custom widget - flips this + // .model root flag so the Harmonia shell template suppresses the auto per-entity count tiles + // at generation time: declared widgets replace them. (Report-attached widget definitions + // live in the .report files, read by the runtime store; custom widgets are baked below.) + boolean reportWidgets = model.getReports() + .stream() + .anyMatch(report -> report.getWidget() != null); + List> customWidgets = buildCustomWidgets(model); + if (reportWidgets || !customWidgets.isEmpty()) { + body.put("dashboardKpis", Boolean.TRUE); + } + // Custom dashboard widgets (top-level `widgets:`): developer-supplied REST KPIs (kind kpi - + // the url returns {value, description?}) and embedded pages (kind page - the url is iframed). + // Carried on the .model root; the Harmonia shell template bakes them into the dashboard. + if (!customWidgets.isEmpty()) { + body.put("widgets", customWidgets); + } body.put("entities", entityList); body.put("perspectives", perspectiveList); body.put("navigations", new ArrayList<>()); @@ -434,6 +453,43 @@ private static boolean notBlank(String value) { return value != null && !value.isBlank(); } + /** + * The custom dashboard widgets for the {@code .model} root: name, kind ({@code kpi} default / + * {@code page}), the developer's same-origin URL, presentation defaults, and a {@code tId} that + * lands in the model's translation catalog. Unnamed/duplicate widgets are skipped with a warning + * (the parser already reports them as validation issues). + */ + private static List> buildCustomWidgets(IntentModel model) { + List> widgets = new ArrayList<>(); + Set seen = new HashSet<>(); + for (CustomWidgetIntent widget : model.getWidgets()) { + if (widget.getName() == null || widget.getName() + .isBlank() + || !seen.add(widget.getName())) { + LOGGER.warn("Skipping unnamed or duplicate custom widget in intent [{}]", model.getName()); + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", widget.getName()); + entry.put("kind", notBlank(widget.getKind()) ? widget.getKind() + .trim() + : "kpi"); + entry.put("url", widget.getUrl()); + entry.put("label", notBlank(widget.getLabel()) ? widget.getLabel() : IntentNaming.humanize(widget.getName())); + entry.put("tId", "widget" + widget.getName() + .replace(" ", "") + .replace("_", "") + .replace(".", "") + .replace(":", "")); + entry.put("icon", notBlank(widget.getIcon()) ? widget.getIcon() : "gauge"); + if (notBlank(widget.getDescription())) { + entry.put("description", widget.getDescription()); + } + widgets.add(entry); + } + return widgets; + } + private static Map perspectiveEntry(String name, int order, String icon) { Map perspective = new LinkedHashMap<>(); perspective.put("name", name); 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 0b43ed58cf..d7115b71af 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 @@ -31,6 +31,7 @@ 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.eclipse.dirigible.components.intent.model.WidgetIntent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; @@ -141,6 +142,11 @@ private static Map build(IntentGenerationContext context, Report List> columns = new ArrayList<>(); List selectParts = new ArrayList<>(); List groupParts = new ArrayList<>(); + // Widget resolution inputs: the column each authored dimension/measure expression produced + // (keyed by the whitespace/case-insensitive expression), plus the date-bucket function of a + // month(x)/year(x) dimension so the KPI runtime can resolve the `now` token type-aware. + Map dimensionColumns = new LinkedHashMap<>(); + Map> measureColumns = new LinkedHashMap<>(); for (String dimension : report.getDimensions()) { if (dimension == null || dimension.isBlank()) { @@ -162,7 +168,9 @@ private static Map build(IntentGenerationContext context, Report ? "(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)); + Map bucketColumn = column(ref.tableAlias, alias, ref.physicalColumn, "INTEGER", "NONE", aggregated); + columns.add(bucketColumn); + dimensionColumns.put(expressionKey(dimension), new WidgetDimension(bucketColumn, function)); selectParts.add(expression + " as \"" + alias + "\""); if (aggregated) { groupParts.add(expression); @@ -171,7 +179,10 @@ private static Map build(IntentGenerationContext context, Report } 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)); + Map dimensionColumn = + column(ref.tableAlias, ref.displayAlias, ref.physicalColumn, ref.reportType, "NONE", aggregated); + columns.add(dimensionColumn); + dimensionColumns.put(expressionKey(dimension), new WidgetDimension(dimensionColumn, null)); selectParts.add(ref.qualified() + " as \"" + ref.displayAlias + "\""); if (aggregated) { groupParts.add(ref.qualified()); @@ -181,7 +192,11 @@ private static Map build(IntentGenerationContext context, Report if (measure == null || measure.isBlank()) { continue; } + int before = columns.size(); addMeasure(context, model, source, baseAlias, measure.trim(), joins, columns, selectParts); + if (columns.size() > before) { + measureColumns.put(expressionKey(measure), columns.get(before)); + } } String where = buildWhere(context, model, source, baseAlias, joins, report.getFilter()); @@ -200,6 +215,9 @@ private static Map build(IntentGenerationContext context, Report // dashboard: false excludes the report's tile from the home dashboard (it still shows in the // sidebar). Carried on the .report; the Harmonia reports store reads it. document.put("dashboard", report.isDashboardExcluded() ? Boolean.FALSE : Boolean.TRUE); + if (report.getWidget() != null) { + document.put("widget", widget(report, dimensionColumns, measureColumns)); + } document.put("columns", columns); document.put("query", query); document.put("conditions", conditions(context, model, source, baseAlias, report.getFilter())); @@ -236,6 +254,86 @@ private static void addMeasure(IntentGenerationContext context, IntentModel mode LOGGER.warn("Measure [{}] did not match the aggregate(field) convention - skipping", measure); } + /** + * A dimension's emitted column plus its date-bucket function ({@code month}/{@code year}), if any. + */ + record WidgetDimension(Map column, String bucket) { + } + + /** + * The report's dashboard KPI, resolved from authored expressions to the report's own column aliases + * so the runtime can query the generated report controller directly: {@code kind: count} uses the + * count endpoint, {@code kind: value} reads {@code valueColumn} off the row matching the {@code at} + * pins (typed EQ conditions), {@code kind: list} shows the first {@code limit} rows. The + * {@code now} token stays symbolic - the dashboard resolves it client-side, type-aware per the + * pinned column's {@code bucket}/{@code type}. No SQL and no URLs live in this block. + */ + static Map widget(ReportIntent report, Map dimensionColumns, + Map> measureColumns) { + WidgetIntent intent = report.getWidget(); + String kind = intent.getKind() != null && !intent.getKind() + .isBlank() ? intent.getKind() + .trim() + : (intent.getValue() != null ? "value" : "count"); + Map widget = new LinkedHashMap<>(); + widget.put("kind", kind); + widget.put("label", intent.getLabel() != null && !intent.getLabel() + .isBlank() ? intent.getLabel() : humanize(report.getName())); + widget.put("tId", translationId("widget" + report.getName())); + widget.put("icon", intent.getIcon() != null && !intent.getIcon() + .isBlank() ? intent.getIcon() : "gauge"); + if ("value".equals(kind)) { + Map measureColumn = measureColumns.get(expressionKey(intent.getValue())); + if (measureColumn == null) { + LOGGER.warn("Widget of report [{}] references measure [{}] which produced no column - the KPI will not resolve", + report.getName(), intent.getValue()); + } else { + widget.put("valueColumn", measureColumn.get("alias")); + widget.put("valueType", measureColumn.get("type")); + if (measureColumn.containsKey("pattern")) { + widget.put("pattern", measureColumn.get("pattern")); + } + } + } + if ("list".equals(kind)) { + widget.put("limit", intent.getLimit() == null ? 5 : intent.getLimit()); + } + List> pins = new ArrayList<>(); + for (Map.Entry at : intent.getAt() + .entrySet()) { + WidgetDimension dimension = dimensionColumns.get(expressionKey(at.getKey())); + if (dimension == null) { + LOGGER.warn("Widget of report [{}] pins unknown dimension [{}] - skipping the pin", report.getName(), at.getKey()); + continue; + } + Map pin = new LinkedHashMap<>(); + pin.put("column", dimension.column() + .get("alias")); + pin.put("type", dimension.column() + .get("type")); + if (dimension.bucket() != null) { + pin.put("bucket", dimension.bucket()); + } + if ("now".equals(at.getValue())) { + pin.put("token", "now"); + } else { + pin.put("value", at.getValue()); + } + pins.add(pin); + } + if (!pins.isEmpty()) { + widget.put("at", pins); + } + return widget; + } + + /** Whitespace/case-insensitive compare key for authored measure and dimension expressions. */ + private static String expressionKey(String expression) { + return expression == null ? "" + : expression.replaceAll("\\s+", "") + .toLowerCase(Locale.ROOT); + } + /** * Resolve a dimension/measure field reference to its physical column, joining when it crosses a * relation. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CustomWidgetIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CustomWidgetIntent.java new file mode 100644 index 0000000000..e83409e36d --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CustomWidgetIntent.java @@ -0,0 +1,85 @@ +/* + * 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; + +/** + * A custom dashboard widget (top-level {@code widgets:} block) whose content the developer supplies + * — the escape hatch beside the report-attached KPIs ({@code reports[].widget}): + *
    + *
  • {@code kind: kpi} — a compact number tile; {@link #url} is a REST endpoint (typically a + * client-Java {@code @Controller} under the project's {@code custom/} folder) returning + * {@code {value, description?}};
  • + *
  • {@code kind: page} — a large tile embedding the HTML page at {@link #url} (like a report + * preview tile).
  • + *
+ * The kind implies how the URL is consumed (JSON fetch vs iframe) — there is no separate source + * type. + */ +public class CustomWidgetIntent { + + private String name; + /** {@code kpi} (a number tile fed by a REST endpoint) or {@code page} (an embedded HTML page). */ + private String kind; + /** Same-origin URL of the REST endpoint ({@code kpi}) or the HTML page ({@code page}). */ + private String url; + /** Tile label; defaults to the humanized name. */ + private String label; + /** Lucide icon name; defaults to {@code gauge}. */ + private String icon; + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java index cc53515632..8ec553a53e 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -47,6 +47,8 @@ public class IntentModel { private List processes = new ArrayList<>(); private List forms = new ArrayList<>(); private List reports = new ArrayList<>(); + /** Custom dashboard widgets — developer-supplied REST KPIs and embedded pages. */ + private List widgets = new ArrayList<>(); private List permissions = new ArrayList<>(); private List seeds = new ArrayList<>(); private List notifications = new ArrayList<>(); @@ -136,6 +138,14 @@ public void setReports(List reports) { this.reports = reports == null ? new ArrayList<>() : reports; } + public List getWidgets() { + return widgets; + } + + public void setWidgets(List widgets) { + this.widgets = widgets == null ? new ArrayList<>() : widgets; + } + public List getPermissions() { return permissions; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java index 3bdad444b2..a5973c30fb 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java @@ -30,6 +30,11 @@ public class ReportIntent { * {@code dashboard: false} excludes it (it still appears in the sidebar Reports section). */ private Boolean dashboard; + /** + * Optional dashboard KPI derived from this report; when present, the dashboard shows the KPI tile + * instead of the report's preview tile. + */ + private WidgetIntent widget; public String getName() { return name; @@ -91,4 +96,12 @@ public Boolean getDashboard() { public void setDashboard(Boolean dashboard) { this.dashboard = dashboard; } + + public WidgetIntent getWidget() { + return widget; + } + + public void setWidget(WidgetIntent widget) { + this.widget = widget; + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/WidgetIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/WidgetIntent.java new file mode 100644 index 0000000000..70facf85fa --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/WidgetIntent.java @@ -0,0 +1,89 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; + +/** + * Dashboard KPI attached to a report ({@code reports[].widget}). The report supplies the data + * (source, dimensions, measures, filter); the widget only says which single number the dashboard + * tile shows: + *
    + *
  • {@code kind: count} (the default) — the number of records the report yields;
  • + *
  • {@code kind: value} — one aggregate cell: {@link #value} names a declared measure and + * {@link #at} pins dimension columns to a predefined token ({@code now}, resolved client-side and + * type-aware) or a literal;
  • + *
  • {@code kind: list} — the report's first {@link #limit} rows as a compact table tile.
  • + *
+ */ +public class WidgetIntent { + + /** {@code count} (default), {@code value} or {@code list}. */ + private String kind; + /** For {@code kind: value}: the declared measure supplying the number, e.g. {@code sum(total)}. */ + private String value; + /** Dimension pins: authored dimension → token ({@code now}) or literal. */ + private Map at = new LinkedHashMap<>(); + /** Tile label; defaults to the report label. */ + private String label; + /** Lucide icon name; defaults to {@code gauge}. */ + private String icon; + /** For {@code kind: list}: how many rows the tile shows (default 5). */ + private Integer limit; + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public Map getAt() { + return at; + } + + public void setAt(Map at) { + this.at = at == null ? new LinkedHashMap<>() : at; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } +} 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 f46b4cfffd..9bcd9b4767 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 @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Set; +import org.eclipse.dirigible.components.intent.model.CustomWidgetIntent; import org.eclipse.dirigible.components.intent.model.DependsOnIntent; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; @@ -34,6 +35,7 @@ import org.eclipse.dirigible.components.intent.model.ScheduleIntent; import org.eclipse.dirigible.components.intent.model.SeedIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; +import org.eclipse.dirigible.components.intent.model.WidgetIntent; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -152,6 +154,7 @@ private static void validate(IntentModel model) { validateProcesses(model, entityNames, issues); validateForms(model, entityNames, issues); validateReports(model, entityNames, issues); + validateWidgets(model, issues); validateSeeds(model, entityNames, issues); validateLanguages(model, issues); validateNotifications(model, entityNames, issues); @@ -1170,9 +1173,106 @@ private static void validateReports(IntentModel model, Set entityNames, } else if (!entityNames.contains(report.getSource())) { issues.add("report [" + report.getName() + "] sources from unknown entity [" + report.getSource() + "]"); } + if (report.getWidget() != null) { + validateReportWidget(report, issues); + } + } + } + + private static final Set WIDGET_KINDS = Set.of("count", "value", "list"); + private static final Set CUSTOM_WIDGET_KINDS = Set.of("kpi", "page"); + + /** + * Top-level {@code widgets:} — custom dashboard widgets: {@code kind: kpi} fetches {@code {value, + * description?}} from the developer's REST endpoint, {@code kind: page} embeds the developer's HTML + * page. The URL is the developer's own contract (typically code under {@code custom/}), so only its + * shape is checked: same-origin (an absolute or relative path, no scheme/host) to keep the + * dashboard's fetch/iframe inside the application. + */ + private static void validateWidgets(IntentModel model, List issues) { + Set widgetNames = new HashSet<>(); + for (CustomWidgetIntent widget : model.getWidgets()) { + if (widget.getName() == null || widget.getName() + .isBlank()) { + issues.add("widget has no name"); + continue; + } + String prefix = "widget [" + widget.getName() + "]"; + if (!widgetNames.add(widget.getName())) { + issues.add("duplicate widget [" + widget.getName() + "]"); + } + String kind = widget.getKind() == null ? "kpi" + : widget.getKind() + .trim(); + if (!CUSTOM_WIDGET_KINDS.contains(kind)) { + issues.add(prefix + " has unknown kind [" + widget.getKind() + "] - expected one of " + CUSTOM_WIDGET_KINDS); + } + String url = widget.getUrl(); + if (url == null || url.isBlank()) { + issues.add(prefix + " has no url"); + } else if (url.contains("://") || url.startsWith("//")) { + issues.add(prefix + " url must be a same-origin path (no scheme/host): [" + url + "]"); + } } } + /** + * A report {@code widget:} block turns the report into a dashboard KPI tile. {@code kind: count} + * (default) shows the report's record count; {@code kind: value} shows one aggregate cell - + * {@code value} names a declared measure and {@code at} pins declared dimensions to a token + * ({@code now}) or a literal; {@code kind: list} shows the report's first {@code limit} rows. + * Alias/type resolution happens in the report generator (same leniency as report filters). + */ + private static void validateReportWidget(ReportIntent report, List issues) { + WidgetIntent widget = report.getWidget(); + String prefix = "report [" + report.getName() + "] widget"; + String kind = widget.getKind() == null ? (widget.getValue() != null ? "value" : "count") + : widget.getKind() + .trim(); + if (!WIDGET_KINDS.contains(kind)) { + issues.add(prefix + " has unknown kind [" + widget.getKind() + "] - expected one of " + WIDGET_KINDS); + return; + } + if ("value".equals(kind)) { + if (widget.getValue() == null || widget.getValue() + .isBlank()) { + issues.add(prefix + " of kind [value] requires `value` naming a declared measure"); + } else if (report.getMeasures() + .stream() + .noneMatch(m -> m != null && normalizeExpression(m).equals(normalizeExpression(widget.getValue())))) { + issues.add(prefix + " value [" + widget.getValue() + "] does not name a declared measure"); + } + } else if (widget.getValue() != null) { + issues.add(prefix + " of kind [" + kind + "] must not declare `value` - use kind [value]"); + } + for (Map.Entry pin : widget.getAt() + .entrySet()) { + String dimension = pin.getKey(); + if (report.getDimensions() + .stream() + .noneMatch(d -> d != null && normalizeExpression(d).equals(normalizeExpression(dimension)))) { + issues.add(prefix + " pins unknown dimension [" + dimension + "]"); + } + Object value = pin.getValue(); + if (!(value instanceof String || value instanceof Number || value instanceof Boolean)) { + issues.add(prefix + " pin [" + dimension + "] must be a scalar token or literal"); + } + } + if (widget.getLimit() != null) { + if (!"list".equals(kind)) { + issues.add(prefix + " of kind [" + kind + "] must not declare `limit` - it applies to kind [list] only"); + } else if (widget.getLimit() < 1) { + issues.add(prefix + " limit must be a positive number"); + } + } + } + + /** Whitespace/case-insensitive compare key for measure and dimension expressions. */ + private static String normalizeExpression(String expression) { + return expression.replaceAll("\\s+", "") + .toLowerCase(Locale.ROOT); + } + private static void validateSeeds(IntentModel model, Set entityNames, List issues) { java.util.Map byName = new java.util.HashMap<>(); for (EntityIntent entity : model.getEntities()) { 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 e2aa654fef..83f01870f8 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 @@ -407,6 +407,75 @@ A dimension may bucket a date for aggregation: `month(field)` (a sortable YYYYMM 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. +#### reports[].widget - dashboard KPI tiles + +**Use when:** the user wants a meaningful number on the home dashboard — "overdue invoices", +"revenue this month" — instead of (or besides) the full report. The report supplies the data; the +widget only says which single number (or top-N slice) the tile shows. + +```yaml +reports: + - name: OverdueInvoices + source: Invoice + dimensions: [number, customer.name, dueOn, total] + filter: "dueOn < CURRENT_DATE and status.name <> 'Paid'" + widget: + kind: count # default: the number of records the report yields + label: Overdue Invoices # optional, defaults to the report label + icon: alert-triangle # optional Lucide icon, default gauge + + - name: RevenueByMonth + source: Invoice + dimensions: ["month(issuedOn)"] + measures: ["sum(total)"] + widget: + value: "sum(total)" # names a declared measure => kind: value + at: { "month(issuedOn)": now } # pin dimensions: the token `now` or a literal + label: Revenue (this month) + icon: banknote + + - name: TopDebtors + source: Invoice + dimensions: [customer.name] + measures: ["sum(total)"] + filter: "status.name <> 'Paid'" + widget: { kind: list, limit: 5, label: Top Debtors, icon: list-ordered } +``` + +**Rules:** `kind` is `count` (default) / `value` / `list`. `value` must name a declared measure and +implies `kind: value`; `limit` (default 5) applies to `kind: list` only. `at` keys must name +declared dimensions; the token `now` resolves at view time, type-aware — current YYYYMM on a +`month(x)` dimension, current year on `year(x)`, today on a date column — anything else is a +literal pinned with an equals condition. **Behavior:** a widget-bearing report shows a compact KPI +tile INSTEAD of its dashboard preview tile (click still opens the full report), and declaring any +widget replaces the auto per-entity record-count tiles; `dashboard: false` hides both tiles of a +report. Prefer a handful of business-meaningful widgets over restating entity counts. + +### widgets - custom dashboard widgets + +**Use when:** the dashboard needs content the report machinery cannot express - a number computed +by hand-written code, or an entirely custom visualization page. This is the dashboard's escape +hatch; prefer `reports[].widget` when a report can supply the number. + +```yaml +widgets: + - name: SystemHealth + kind: kpi # default: a number tile fed by a REST endpoint + url: /services/js/sales/custom/health.js # GET returns { value, description? } + label: System Health # optional, defaults to the humanized name + icon: activity # optional Lucide icon, default gauge + - name: SalesFunnel + kind: page # a large tile embedding the developer's HTML page + url: /services/web/sales/custom/funnel/index.html +``` + +**Rules:** `kind` is `kpi` (default) or `page` - the kind implies how the `url` is consumed (JSON +fetch vs iframe), so there is no separate source-type field. `url` must be a same-origin path (no +scheme/host); the implementation is hand-written code under the project's `custom/` folder (e.g. a +client-Java `@Component @Controller`) or any served page. A `kpi` endpoint returns +`{ "value": , "description": "optional secondary line" }`. Declaring any widget +(custom or report-attached) replaces the auto per-entity record-count tiles. + ### permissions - roles **Use when:** different users may do different things. 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 0075e3d9f1..1aa6b38677 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 @@ -216,6 +216,69 @@ void multilingualEntityAndLanguagesFlowIntoTheModel() { assertEquals(List.of("en", "bg"), languages, "the intent's languages should land on the .model root"); } + @Test + void dashboardWidgetsFlowIntoTheModelRoot() { + String yaml = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: total, type: decimal } + reports: + - name: OverdueInvoices + source: Invoice + widget: { kind: count, label: Overdue Invoices } + widgets: + - name: SystemHealth + url: /services/js/sales/custom/health.js + - name: SalesFunnel + kind: page + url: /services/web/sales/custom/funnel/index.html + label: Sales Funnel + icon: chart-column + """; + IntentModel parsed = IntentParser.parse(yaml); + Map model = EdmIntentGenerator.buildModelJsonForTest(parsed, "sales"); + @SuppressWarnings("unchecked") + Map root = (Map) model.get("model"); + + assertEquals(Boolean.TRUE, root.get("dashboardKpis"), "declared widgets should flip the .model root flag"); + + @SuppressWarnings("unchecked") + List> widgets = (List>) root.get("widgets"); + assertEquals(2, widgets.size(), "both custom widgets should land on the .model root"); + Map health = widgets.get(0); + assertEquals("kpi", health.get("kind"), "kind should default to kpi"); + assertEquals("System Health", health.get("label"), "label should default to the humanized name"); + assertEquals("widgetSystemHealth", health.get("tId")); + assertEquals("gauge", health.get("icon"), "icon should default to gauge"); + Map funnel = widgets.get(1); + assertEquals("page", funnel.get("kind")); + assertEquals("/services/web/sales/custom/funnel/index.html", funnel.get("url")); + } + + @Test + void reportWidgetAloneFlipsTheDashboardKpisFlagWithoutCustomWidgets() { + String yaml = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + reports: + - name: OverdueInvoices + source: Invoice + widget: { kind: count } + """; + IntentModel parsed = IntentParser.parse(yaml); + @SuppressWarnings("unchecked") + Map root = (Map) EdmIntentGenerator.buildModelJsonForTest(parsed, "sales") + .get("model"); + assertEquals(Boolean.TRUE, root.get("dashboardKpis")); + assertNull(root.get("widgets"), "no custom widgets - the .model root must not carry an empty array"); + } + private static Map buildFromResource(String resource, String intentName) { IntentModel parsed = IntentParser.parse(readResource(resource)); return EdmIntentGenerator.buildModelJsonForTest(parsed, intentName); diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java new file mode 100644 index 0000000000..36386d1493 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java @@ -0,0 +1,152 @@ +/* + * 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.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.ReportIntent; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The widget block a report-attached KPI emits into the {@code .report}: authored expressions are + * resolved to the report's own column aliases (the tracking maps {@code build} assembles), the + * {@code now} token stays symbolic, defaults apply. The full build path (alias tracking inside the + * dimension/measure loops) is covered end-to-end by {@code IntentEngineIT}. + */ +class ReportIntentGeneratorTest { + + private static final String INTENT = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: issuedOn, type: date } + - { name: total, type: decimal } + reports: + - name: RevenueByMonth + source: Invoice + dimensions: ["month(issuedOn)"] + measures: ["sum(total)"] + widget: + value: "sum(total)" + at: { "month(issuedOn)": now } + label: Revenue (this month) + icon: banknote + """; + + private static ReportIntent report() { + IntentModel model = IntentParser.parse(INTENT); + return model.getReports() + .get(0); + } + + /** The column maps as {@code build} tracks them for the widget resolution. */ + private static Map column(String alias, String type, String pattern) { + Map column = new LinkedHashMap<>(); + column.put("alias", alias); + column.put("type", type); + if (pattern != null) { + column.put("pattern", pattern); + } + return column; + } + + @Test + void valueWidgetResolvesMeasureColumnAndPinsTheBucketDimension() { + Map dimensions = new LinkedHashMap<>(); + dimensions.put("month(issuedon)", new ReportIntentGenerator.WidgetDimension(column("Month Issued On", "INTEGER", null), "month")); + Map> measures = new LinkedHashMap<>(); + measures.put("sum(total)", column("Sum Total", "DECIMAL", "### ### ### ##0.00")); + + Map widget = ReportIntentGenerator.widget(report(), dimensions, measures); + + assertEquals("value", widget.get("kind")); + assertEquals("Revenue (this month)", widget.get("label")); + assertEquals("banknote", widget.get("icon")); + assertEquals("widgetRevenueByMonth", widget.get("tId")); + assertEquals("Sum Total", widget.get("valueColumn")); + assertEquals("DECIMAL", widget.get("valueType")); + assertEquals("### ### ### ##0.00", widget.get("pattern")); + + @SuppressWarnings("unchecked") + List> pins = (List>) widget.get("at"); + assertEquals(1, pins.size()); + Map pin = pins.get(0); + assertEquals("Month Issued On", pin.get("column")); + assertEquals("INTEGER", pin.get("type")); + assertEquals("month", pin.get("bucket")); + assertEquals("now", pin.get("token")); + assertNull(pin.get("value")); + } + + @Test + void countWidgetDefaultsKindLabelAndIcon() { + ReportIntent report = report(); + report.getWidget() + .setValue(null); + report.getWidget() + .setKind(null); + report.getWidget() + .setLabel(null); + report.getWidget() + .setIcon(null); + report.getWidget() + .setAt(null); + + Map widget = ReportIntentGenerator.widget(report, new LinkedHashMap<>(), new LinkedHashMap<>()); + + assertEquals("count", widget.get("kind")); + assertEquals("Revenue By Month", widget.get("label")); + assertEquals("gauge", widget.get("icon")); + assertFalse(widget.containsKey("valueColumn")); + assertFalse(widget.containsKey("at")); + assertFalse(widget.containsKey("limit")); + } + + @Test + void listWidgetCarriesTheLimitAndLiteralPinsKeepTheirValue() { + ReportIntent report = report(); + report.getWidget() + .setValue(null); + report.getWidget() + .setKind("list"); + report.getWidget() + .setLimit(3); + report.getWidget() + .getAt() + .put("month(issuedOn)", 202601L); + + Map dimensions = new LinkedHashMap<>(); + dimensions.put("month(issuedon)", new ReportIntentGenerator.WidgetDimension(column("Month Issued On", "INTEGER", null), "month")); + + Map widget = ReportIntentGenerator.widget(report, dimensions, new LinkedHashMap<>()); + + assertEquals("list", widget.get("kind")); + assertEquals(3, widget.get("limit")); + @SuppressWarnings("unchecked") + List> pins = (List>) widget.get("at"); + // A non-`now` pin is a literal: it keeps its value instead of becoming a token. + assertEquals(1, pins.size()); + assertEquals(202601L, pins.get(0) + .get("value")); + assertNull(pins.get(0) + .get("token")); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index ed0b31af0a..3b215540ba 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -422,4 +422,146 @@ void dependsOnOnDocumentStatusRelationIsRejected() { .anyMatch(i -> i.contains("documentStatus (a read-only pill) so it cannot declare dependsOn")), "expected a documentStatus-dependent issue, got: " + ex.getIssues()); } + + private static final String WIDGET_HEAD = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: issuedOn, type: date } + - { name: total, type: decimal } + reports: + - name: RevenueByMonth + source: Invoice + dimensions: ["month(issuedOn)"] + measures: ["sum(total)"] + widget: + value: "sum(total)" + at: { "month(issuedOn)": now } + """; + + @Test + void reportWidgetParses() { + IntentModel model = IntentParser.parse(WIDGET_HEAD); + assertEquals("sum(total)", model.getReports() + .get(0) + .getWidget() + .getValue()); + assertEquals("now", model.getReports() + .get(0) + .getWidget() + .getAt() + .get("month(issuedOn)")); + } + + @Test + void widgetValueMustNameADeclaredMeasure() { + String yaml = WIDGET_HEAD.replace("value: \"sum(total)\"", "value: \"avg(total)\""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("does not name a declared measure")), + "expected an unknown-measure issue, got: " + ex.getIssues()); + } + + @Test + void widgetPinMustNameADeclaredDimension() { + String yaml = WIDGET_HEAD.replace("at: { \"month(issuedOn)\": now }", "at: { \"year(issuedOn)\": now }"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("pins unknown dimension [year(issuedOn)]")), + "expected an unknown-dimension issue, got: " + ex.getIssues()); + } + + @Test + void widgetUnknownKindIsRejected() { + String yaml = WIDGET_HEAD.replace("value: \"sum(total)\"", "kind: chart"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("unknown kind [chart]")), + "expected an unknown-kind issue, got: " + ex.getIssues()); + } + + @Test + void widgetLimitIsListOnly() { + String yaml = WIDGET_HEAD.stripTrailing() + "\n limit: 5\n"; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("must not declare `limit`")), + "expected a limit-misuse issue, got: " + ex.getIssues()); + } + + @Test + void widgetValueOnCountKindIsRejected() { + String yaml = WIDGET_HEAD.replace("widget:", "widget:\n kind: count"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("must not declare `value`")), + "expected a value-on-count issue, got: " + ex.getIssues()); + } + + private static final String CUSTOM_WIDGET_HEAD = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + widgets: + - name: SystemHealth + kind: kpi + url: /services/js/sales/custom/health.js + label: System Health + icon: activity + - name: SalesFunnel + kind: page + url: /services/web/sales/custom/funnel/index.html + """; + + @Test + void customWidgetsParse() { + IntentModel model = IntentParser.parse(CUSTOM_WIDGET_HEAD); + assertEquals(2, model.getWidgets() + .size()); + assertEquals("kpi", model.getWidgets() + .get(0) + .getKind()); + assertEquals("/services/web/sales/custom/funnel/index.html", model.getWidgets() + .get(1) + .getUrl()); + } + + @Test + void customWidgetWithoutUrlIsRejected() { + String yaml = CUSTOM_WIDGET_HEAD.replace(" url: /services/js/sales/custom/health.js\n", ""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("widget [SystemHealth] has no url")), + "expected a missing-url issue, got: " + ex.getIssues()); + } + + @Test + void customWidgetWithCrossOriginUrlIsRejected() { + String yaml = CUSTOM_WIDGET_HEAD.replace("/services/js/sales/custom/health.js", "https://example.com/kpi"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("url must be a same-origin path")), + "expected a same-origin issue, got: " + ex.getIssues()); + } + + @Test + void customWidgetWithUnknownKindIsRejected() { + String yaml = CUSTOM_WIDGET_HEAD.replace("kind: kpi", "kind: chart"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("has unknown kind [chart]")), + "expected an unknown-kind issue, got: " + ex.getIssues()); + } } diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js index ce2489a188..cd34fb76db 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js @@ -97,7 +97,7 @@ document.addEventListener('alpine:init', () => { if (SHELL[top]) { const selectedReport = top === 'reports' && this.$store.reports && this.$store.reports.selected; crumbs.push({ label: SHELL[top], route: selectedReport ? '/' + top : null }); - if (selectedReport) crumbs.push({ label: this.$store.reports.selected.label, route: null }); + if (selectedReport) crumbs.push({ label: this.$store.reports.displayLabel(this.$store.reports.selected), route: null }); return crumbs; } diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/reports.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/reports.js index fee8b3d809..ce25f9084f 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/reports.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/reports.js @@ -22,6 +22,16 @@ * (Pragmatic discovery via the core repository API. A cleaner long-term option is for the intent to * surface its reports in the model the shell is generated from.) */ +// Mirror of service-generate/template/parameterUtils.js sanitizeJavaIdentifier — the rule that maps +// a raw gen-folder name to the Java package folder the generated report controller lives under. +// Keep the two in sync: lowercase, every non-[a-z0-9_] char -> '_', digit-prefixed -> '_'-prefixed. +function sanitizeJavaIdentifier(name) { + if (name === undefined || name === null || name === '') return '_'; + let s = String(name).toLowerCase().replace(/[^a-z0-9_]/g, '_'); + if (/^[0-9]/.test(s)) s = '_' + s; + return s === '' ? '_' : s; +} + // "MembersWithLoansDue" -> "Members With Loans Due" (PascalCase/camelCase -> spaced Title Case). function humanizeReportName(s) { return String(s || '') @@ -86,12 +96,93 @@ document.addEventListener('alpine:init', () => { if (def.label) it.label = def.label; if (def.description) it.description = def.description; if (def.dashboard === false) it.dashboard = false; + if (def.tId) it.tId = def.tId; + // A report-attached KPI widget: the dashboard shows a compact KPI tile (count / single + // aggregate value / top-N list) instead of the report's iframe preview tile. + if (def.widget) { + it.widget = def.widget; + it.columns = def.columns || []; + } } catch (e) { /* keep defaults */ } })); // Reassign so Alpine re-renders the sidebar / dashboard with the enriched items. this.items = [...this.items]; }, + // Translated display labels. Reports ship per-project catalogs under the '-report' + // translation prefix (the report template's translate action); the fully-qualified i18next key + // is ':-report.t.'. In the default language the baked label wins (window.T's + // contract), and without the i18n service (or a tId off an older .report) the raw label renders. + tkey(it, tId) { return it.project + ':' + it.name + '-report.t.' + tId; }, + displayLabel(it) { + return (window.T && it.tId) ? T(this.tkey(it, it.tId), it.label) : it.label; + }, + widgetLabel(it) { + const w = it.widget || {}; + return (window.T && w.tId) ? T(this.tkey(it, w.tId), w.label) : w.label; + }, + + // Load a KPI widget's data from the report's generated controller. Returns + // { value } for kind count/value (missing data coalesces to 0), + // { rows } for kind list, + // { forbidden: true } when the report is role-guarded and the user lacks the role + // (the tile should be hidden, not shown as an error), + // { error: true } on any other failure. + // `at` pins become typed EQ conditions over the report's output columns — the same + // per-column filter contract the report page's filter panel uses. The `now` token is + // resolved here, type-aware: month(x) bucket -> current YYYYMM, year(x) -> current year, + // date column -> today ISO. + async loadWidgetValue(it) { + const w = it.widget; + if (!w || !it.apiBase) return { error: true }; + try { + const conditions = (w.at || []).map(pin => ({ column: pin.column, operator: 'EQ', value: this._pinValue(pin) })); + if (w.kind === 'value') { + const rows = await this._fetchJson(it.apiBase + '/search', { conditions, $limit: 1 }); + const v = rows && rows.length ? rows[0][w.valueColumn] : 0; + return { value: v === null || v === undefined ? 0 : v }; + } + if (w.kind === 'list') { + const rows = await this._fetchJson(it.apiBase + '/search', { conditions, $limit: w.limit || 5 }); + return { rows: rows || [] }; + } + // kind: count (the default) — the number of records the report yields. + const r = conditions.length + ? await this._fetchJson(it.apiBase + '/count', { conditions }) + : await this._fetchJson(it.apiBase + '/count'); + return { value: r && typeof r.count === 'number' ? r.count : 0 }; + } catch (e) { + if (e && e.status === 403) return { forbidden: true }; + console.error('reports: widget load failed for ' + it.name, e); + return { error: true }; + } + }, + + _pinValue(pin) { + if (pin.token === 'now') { + const d = new Date(); + if (pin.bucket === 'month') return d.getFullYear() * 100 + (d.getMonth() + 1); + if (pin.bucket === 'year') return d.getFullYear(); + // A plain date column: today as the ISO date the typed condition parameter expects. + return d.toISOString().slice(0, 10); + } + return pin.value; + }, + + // GET when no body, POST with a JSON body otherwise; throws { status } on a non-OK response. + async _fetchJson(url, body) { + const r = await fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers: body === undefined + ? { 'Accept': 'application/json' } + : { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!r.ok) { const err = new Error('HTTP ' + r.status + ' for ' + url); err.status = r.status; throw err; } + return r.json(); + }, + // Walk the registry tree (collections + resources); a report page is a `reports//index.html`. _walk(node, out) { if (!node) return; @@ -103,7 +194,15 @@ document.addEventListener('alpine:init', () => { const m = path.match(/\/reports\/([^/]+)\/index\.html$/); if (m) { const pm = path.match(/\/registry\/public\/([^/]+)\//); - out.push({ name: m[1], label: humanizeReportName(m[1]), url: path.replace('/registry/public/', '/services/web/'), project: pm ? pm[1] : '' }); + // The generated report controller URL, derived from the page path: the Java package folder + // is the SANITIZED gen-folder name and the perspective folder is the sanitized constant + // 'reports' (mirrors report.js.template's apiBase). Absent when the path doesn't match the + // gen//reports/ convention — then a KPI widget on this report can't load. + const gm = path.match(/\/registry\/public\/([^/]+)\/gen\/([^/]+)\/reports\/([^/]+)\/index\.html$/); + const apiBase = gm + ? '/services/java/' + gm[1] + '/gen/' + sanitizeJavaIdentifier(gm[2]) + '/api/reports/' + gm[3] + 'Controller' + : ''; + out.push({ name: m[1], label: humanizeReportName(m[1]), url: path.replace('/registry/public/', '/services/web/'), project: pm ? pm[1] : '', apiBase }); } } }); diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json index b5422aa7aa..54f2923fe0 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json @@ -23,7 +23,8 @@ }, "dashboard": { "title": "Табло", - "overview": "Преглед на публикуваните приложения." + "overview": "Преглед на публикуваните приложения.", + "kpis": "Ключови показатели" }, "inbox": { "refresh": "Обнови", diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json index 19e5234a49..8d604a9a84 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json @@ -23,7 +23,8 @@ }, "dashboard": { "title": "Dashboard", - "overview": "Overview of your published applications." + "overview": "Overview of your published applications.", + "kpis": "Key Indicators" }, "inbox": { "refresh": "Refresh", diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js index 0759f9319e..49c5754960 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js @@ -25,6 +25,12 @@ export function generate(model, parameters) { // Data-language codes the app offers (the intent's `languages:`, carried on the .model root). // Rendered into config.js as a JSON array; the shell's Region & Language setting lists them. parameters.appLanguages = JSON.stringify(Array.isArray(model.languages) && model.languages.length ? model.languages : ['en']); + // Declared dashboard widgets exist (the .model root flag EdmIntentGenerator sets): they replace + // the auto per-entity count tiles, so the dashboard skips baking them. Custom widgets (top-level + // intent `widgets:` — REST KPIs and embedded pages) ride the .model root and are baked into the + // dashboard page directly. + parameters.hasWidgets = model.dashboardKpis === true; + parameters.customWidgets = model.widgets || []; return generateUtils.generateFiles(model, parameters, templateSources); }; diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/reportFile.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/reportFile.js index 8eae29781b..7440970e7b 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/reportFile.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/reportFile.js @@ -13,6 +13,13 @@ */ export function getSources() { return [ + // The report's label catalog: the report tId/label, one entry per column, and the KPI + // widget's tile label when present — under the '-report' translation prefix, consumed + // by the shell's i18n service (sidebar entry, dashboard tiles, breadcrumb). + { + location: "/template-application-ui-harmonia-java/ui/translations-report.json.template", + action: "translate", + }, // --- Framework-neutral Java backend (same as the AngularJS report-file template) --- { location: "/template-application-dao-java/data/reportFileEntity.java.template", diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/dashboard.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/dashboard.html.template index 3fdb2c2513..713ae8538f 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/dashboard.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/dashboard.html.template @@ -6,8 +6,80 @@

+ +
+
+ +
+
+ + +
+
+ -
+
@@ -30,6 +102,27 @@
+ +
+
+ +
+
+
@@ -40,7 +133,7 @@

- +

+ + + + + + + + + + + + + + + + + +
Pinned ColumnPinned To
No pins - the tile uses the whole report result
{{pin.column}}{{pin.token ? 'now' : pin.value}} + +
+ + + + + + + diff --git a/components/ui/editor-report/src/main/resources/META-INF/dirigible/editor-report/js/editor.js b/components/ui/editor-report/src/main/resources/META-INF/dirigible/editor-report/js/editor.js index 5a0feed243..137e711c76 100644 --- a/components/ui/editor-report/src/main/resources/META-INF/dirigible/editor-report/js/editor.js +++ b/components/ui/editor-report/src/main/resources/META-INF/dirigible/editor-report/js/editor.js @@ -132,6 +132,10 @@ angular.module('page', ['blimpKit', 'platformView', 'platformShortcuts', 'Worksp $scope.$evalAsync(() => { if (response.data === '') $scope.report = {}; else $scope.report = migrateReport(response.data); + $scope.widgetEnabled = !!$scope.report.widget; + // Absent means shown on the dashboard - make it explicit so the checkbox binds + // cleanly (before the dirty-tracking snapshot below, so this is not a change). + if ($scope.report.dashboard === undefined) $scope.report.dashboard = true; contents = JSON.stringify($scope.report, null, 4); $scope.query = $scope.report.query; $scope.state.isBusy = false; @@ -1531,4 +1535,135 @@ angular.module('page', ['blimpKit', 'platformView', 'platformShortcuts', 'Worksp }; // End Security Section ---------------------------------------------------------------------------------------- + + // Begin Dashboard Widget Section ------------------------------------------------------------------------------ + // The `widget` block turns the report into a dashboard KPI tile: kind `count` shows the report's + // record count, `value` one aggregate cell (a measure column, optionally pinned by `at` equals + // conditions over grouping columns), `list` the first rows as a mini table. Consumed at runtime + // by the Harmonia shell's reports store; the tile replaces the report's dashboard preview tile. + + $scope.widgetKinds = [ + { value: 'count', label: 'Count - the number of records' }, + { value: 'value', label: 'Value - one aggregate cell' }, + { value: 'list', label: 'List - the first rows' }, + ]; + + $scope.toggleWidget = () => { + if ($scope.widgetEnabled) { + if (!$scope.report.widget) { + $scope.report.widget = { + kind: 'count', + label: $scope.report.label || $scope.report.name, + tId: getTranslationId('widget' + ($scope.report.name || '')), + icon: 'gauge', + }; + } + } else { + delete $scope.report.widget; + } + }; + + $scope.widgetKindChanged = () => { + const widget = $scope.report.widget; + if (widget.kind === 'list') { + if (!widget.limit) widget.limit = 5; + } else { + delete widget.limit; + } + if (widget.kind !== 'value') { + delete widget.valueColumn; + delete widget.valueType; + delete widget.pattern; + } + }; + + // The measure the tile shows: an aggregate column of this report. Type and (money) pattern ride + // along so the dashboard can format the number without re-deriving the column. + $scope.widgetMeasureColumns = () => ($scope.report.columns || []).filter(c => c.aggregate && c.aggregate !== 'NONE'); + + $scope.widgetPinColumns = () => ($scope.report.columns || []).filter(c => !c.aggregate || c.aggregate === 'NONE'); + + $scope.widgetValueChanged = () => { + const widget = $scope.report.widget; + const column = ($scope.report.columns || []).find(c => c.alias === widget.valueColumn); + if (column) { + widget.valueType = column.type; + if (column.pattern) widget.pattern = column.pattern; + else delete widget.pattern; + } + }; + + $scope.addWidgetPin = () => { + const options = $scope.widgetPinColumns().map(c => ({ label: c.alias, value: c.alias })); + if (options.length === 0) { + dialogHub.showAlert({ + title: 'No columns to pin', + message: 'Widget pins reference the report\'s non-aggregate (grouping) columns - add such a column first.', + type: AlertTypes.Information, + preformatted: false, + }); + return; + } + dialogHub.showFormDialog({ + title: 'Add widget pin', + form: { + 'wpdColumn': { + label: 'Column', + placeholder: 'Select column', + controlType: 'dropdown', + options: options, + value: options[0].value, + required: true, + }, + 'wpdMode': { + label: 'Pin to', + controlType: 'dropdown', + options: [ + { label: 'Literal value', value: 'literal' }, + { label: 'Now - the current date / period', value: 'now' }, + ], + value: 'literal', + required: true, + }, + 'wpiValue': { + label: 'Value (for a literal pin)', + controlType: 'input', + placeholder: 'Enter value', + type: 'text', + maxlength: 255, + }, + }, + submitLabel: 'Add', + cancelLabel: 'Cancel' + }).then((form) => { + if (form) { + $scope.$evalAsync(() => { + const widget = $scope.report.widget; + if (!widget.at) widget.at = []; + const column = ($scope.report.columns || []).find(c => c.alias === form['wpdColumn']); + const pin = { column: form['wpdColumn'], type: column ? column.type : 'VARCHAR' }; + if (form['wpdMode'] === 'now') pin.token = 'now'; + else pin.value = form['wpiValue']; + widget.at.push(pin); + }); + } + }, (error) => { + console.error(error); + dialogHub.showAlert({ + title: 'New widget pin error', + message: 'There was an error while adding the new widget pin.', + type: AlertTypes.Error, + preformatted: false, + }); + }); + }; + + $scope.deleteWidgetPin = (index) => { + $scope.$evalAsync(() => { + $scope.report.widget.at.splice(index, 1); + if ($scope.report.widget.at.length === 0) delete $scope.report.widget.at; + }); + }; + + // End Dashboard Widget Section -------------------------------------------------------------------------------- }); \ No newline at end of file diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index ef5cfe68f4..a8cb51f4ed 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -37,6 +37,10 @@ function getReportTranslations(report) { for (let i = 0; i < report.columns.length; i++) { translations[report.columns[i]['tId']] = report.columns[i]['label']; } + // A report-attached KPI widget carries its own tile label (shown on the home dashboard). + if (report.widget && report.widget.tId) { + translations[report.widget.tId] = report.widget.label || report.label; + } return translations; } @@ -708,6 +712,14 @@ export function generateFiles(model, parameters, templateSources) { translations.t[model.navigations[i].id] = model.navigations[i].label; } } + // Custom dashboard widgets (the .model root `widgets` array): their tile labels. + if (model.widgets) { + for (let i = 0; i < model.widgets.length; i++) { + if (model.widgets[i].tId) { + translations.t[model.widgets[i].tId] = model.widgets[i].label || model.widgets[i].name; + } + } + } generatedFiles.push({ content: JSON.stringify({ [parameters['tprefix']]: translations }, null, 2), path: `translations/en-US/${parameters.filePath.substring(parameters.filePath.lastIndexOf('/') + 1)}.json` 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 d51f6722f8..3b522bcc4a 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,16 +134,35 @@ class IntentEngineIT extends IntegrationTest { source: Order dimensions: [customer] measures: ["count(*)", "sum(total)"] - # month(field) buckets a date dimension into a sortable YYYYMM integer. + # month(field) buckets a date dimension into a sortable YYYYMM integer. The widget + # turns the report into a dashboard KPI: one aggregate cell, the month pinned to now. - name: OrdersByMonth source: Order dimensions: ["month(orderDate)"] measures: ["count(*)", "sum(total)"] + widget: + value: "sum(total)" + at: { "month(orderDate)": now } + label: Revenue (this month) + icon: banknote - name: BigOrderItems source: OrderItem description: Order items with quantity over one, with their order date dimensions: [order.orderDate, quantity] filter: "quantity > 1" + widget: { kind: count, label: Big Order Items, icon: alert-triangle } + + # Custom dashboard widgets - developer-supplied content: a REST KPI (the url returns + # {value, description?}) and an embedded page tile. + widgets: + - name: SystemHealth + kind: kpi + url: /services/js/orders/custom/health.js + label: System Health + icon: activity + - name: SalesFunnel + kind: page + url: /services/web/orders/custom/funnel.html permissions: - { role: Sales, description: Sales staff, can: [Customer:read, Order:create] } @@ -996,6 +1015,70 @@ void harmonia_form_page_generates_the_depends_on_runtime() { "the item dialog should carry the metadata-driven dependsOn machinery"); } + @Test + void report_widget_generates_the_kpi_block_and_replaces_entity_tiles() { + writeIntent(INTENT_YAML); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + // The .report carries the resolved widget block: authored expressions became column aliases, + // the `now` token stays symbolic (resolved client-side, type-aware via the bucket). + String monthly = contentOf("OrdersByMonth.report"); + assertTrue(monthly.contains("\"kind\": \"value\""), "the value widget should carry its kind"); + assertTrue(monthly.contains("\"valueColumn\": \"Sum Total\""), "value should resolve to the measure column's alias"); + assertTrue(monthly.contains("\"valueType\": \"DECIMAL\""), "the value column type should ride along"); + assertTrue(monthly.contains("\"label\": \"Revenue (this month)\""), "the widget label should be carried"); + assertTrue(monthly.contains("\"bucket\": \"month\""), "a month(x) pin should carry its bucket kind"); + assertTrue(monthly.contains("\"token\": \"now\""), "the now pin should stay a symbolic token"); + assertTrue(monthly.contains("\"column\": \"Month Order Date\""), "the pin should resolve to the dimension column's alias"); + String bigItems = contentOf("BigOrderItems.report"); + assertTrue(bigItems.contains("\"kind\": \"count\""), "the count widget should carry its kind"); + assertTrue(bigItems.contains("\"icon\": \"alert-triangle\""), "the widget icon should be carried"); + + // The .model root flags the declared KPIs so the shell template suppresses the raw + // per-entity count tiles (declared widgets replace them), and carries the custom widgets. + String model = contentOf("orders.model"); + assertTrue(model.contains("\"dashboardKpis\": true"), "the .model root should flag the declared KPI widgets"); + assertTrue(model.contains("\"widgetSystemHealth\""), "the custom kpi widget should land on the .model root with its tId"); + assertTrue(model.contains("\"kind\": \"page\""), "the custom page widget should carry its kind"); + + generateFromModel("template-application-ui-harmonia-java/template/template.js", "orders.model"); + String dashboard = contentOf("gen/orders/js/components/pages/dashboardPage.js"); + assertTrue(dashboard.contains("entities: [],"), "the entity tile list should be baked empty when widgets are declared"); + assertFalse(dashboard.contains("apiPath: '/"), "no entity count tile should be baked when widgets are declared"); + assertTrue(dashboard.contains("loadKpis"), "the dashboard should carry the KPI loading machinery"); + assertTrue(dashboard.contains("loadWidgetValue"), "the KPI tiles should delegate to the reports store's widget fetch"); + // Custom widgets are baked into the page: the kpi fetches its endpoint, the page is iframed. + assertTrue(dashboard.contains("url: '/services/js/orders/custom/health.js'"), + "the custom kpi widget's endpoint should be baked into the dashboard"); + assertTrue(dashboard.contains("kind: 'page'") && dashboard.contains("url: '/services/web/orders/custom/funnel.html'"), + "the custom page widget should be baked with its url"); + assertTrue(dashboard.contains("tkey: '" + PROJECT + ":orders-model.t.widgetSystemHealth'"), + "the custom widget label should carry the model-catalog translation key"); + // ... and its label lands in the model translation catalog. + String modelCatalog = contentOf("translations/en-US/orders.model.json"); + assertTrue(modelCatalog.contains("\"widgetSystemHealth\": \"System Health\""), + "the custom widget's label should land in the model catalog"); + + // The report-file template also emits the report's label catalog (report + columns + the + // widget's tile label) under the '-report' translation prefix. + String reportPayload = + "{\"template\":\"template-application-ui-harmonia-java/template/template-report-file.js\",\"parameters\":{}}"; + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(reportPayload) + .when() + .post("/services/js/service-generate/generate.mjs/model/" + WORKSPACE + "/" + PROJECT + + "?path=OrdersByMonth.report") + .then() + .statusCode(201)); + String catalog = contentOf("translations/en-US/OrdersByMonth.report.json"); + assertTrue(catalog.contains("\"OrdersByMonth-report\""), "the catalog should be keyed by the report translation prefix"); + assertTrue(catalog.contains("\"widgetOrdersByMonth\": \"Revenue (this month)\""), + "the KPI widget's tile label should land in the report catalog"); + assertTrue(catalog.contains("\"OrdersByMonth\": \"Orders By Month\""), "the report label should land in the catalog"); + } + @Test void multilingual_entity_generates_the_translation_stack() { writeIntent(INTENT_YAML);