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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Map<String, Object>> 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<>());
Expand Down Expand Up @@ -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<Map<String, Object>> buildCustomWidgets(IntentModel model) {
List<Map<String, Object>> widgets = new ArrayList<>();
Set<String> 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<String, Object> 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<String, Object> perspectiveEntry(String name, int order, String icon) {
Map<String, Object> perspective = new LinkedHashMap<>();
perspective.put("name", name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -141,6 +142,11 @@ private static Map<String, Object> build(IntentGenerationContext context, Report
List<Map<String, Object>> columns = new ArrayList<>();
List<String> selectParts = new ArrayList<>();
List<String> 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<String, WidgetDimension> dimensionColumns = new LinkedHashMap<>();
Map<String, Map<String, Object>> measureColumns = new LinkedHashMap<>();

for (String dimension : report.getDimensions()) {
if (dimension == null || dimension.isBlank()) {
Expand All @@ -162,7 +168,9 @@ private static Map<String, Object> 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<String, Object> 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);
Expand All @@ -171,7 +179,10 @@ private static Map<String, Object> 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<String, Object> 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());
Expand All @@ -181,7 +192,11 @@ private static Map<String, Object> 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());
Expand All @@ -200,6 +215,9 @@ private static Map<String, Object> 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()));
Expand Down Expand Up @@ -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<String, Object> 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<String, Object> widget(ReportIntent report, Map<String, WidgetDimension> dimensionColumns,
Map<String, Map<String, Object>> measureColumns) {
WidgetIntent intent = report.getWidget();
String kind = intent.getKind() != null && !intent.getKind()
.isBlank() ? intent.getKind()
.trim()
: (intent.getValue() != null ? "value" : "count");
Map<String, Object> 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<String, Object> 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<Map<String, Object>> pins = new ArrayList<>();
for (Map.Entry<String, Object> 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<String, Object> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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}):
* <ul>
* <li>{@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?}};</li>
* <li>{@code kind: page} — a large tile embedding the HTML page at {@link #url} (like a report
* preview tile).</li>
* </ul>
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public class IntentModel {
private List<ProcessIntent> processes = new ArrayList<>();
private List<FormIntent> forms = new ArrayList<>();
private List<ReportIntent> reports = new ArrayList<>();
/** Custom dashboard widgets — developer-supplied REST KPIs and embedded pages. */
private List<CustomWidgetIntent> widgets = new ArrayList<>();
private List<PermissionIntent> permissions = new ArrayList<>();
private List<SeedIntent> seeds = new ArrayList<>();
private List<NotificationIntent> notifications = new ArrayList<>();
Expand Down Expand Up @@ -136,6 +138,14 @@ public void setReports(List<ReportIntent> reports) {
this.reports = reports == null ? new ArrayList<>() : reports;
}

public List<CustomWidgetIntent> getWidgets() {
return widgets;
}

public void setWidgets(List<CustomWidgetIntent> widgets) {
this.widgets = widgets == null ? new ArrayList<>() : widgets;
}

public List<PermissionIntent> getPermissions() {
return permissions;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Loading
Loading