Merge pull request 'feat: KPI / stat tile widget type (#26)' (#33) from ai/issue-26-kpi-tile into main
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
@@ -62,4 +62,25 @@ public final class DialectTheme {
|
|||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for a {@link SparklineChart}: ApexCharts' own
|
||||||
|
* {@code sparkline.enabled} strips axes, grid and legend, so the line fills
|
||||||
|
* the whole (small) container. Built on top of {@link #baseOptions} rather
|
||||||
|
* than from scratch, so palette and font stay shared with the real charts.
|
||||||
|
*/
|
||||||
|
public static Map<String, Object> sparklineOptions() {
|
||||||
|
Map<String, Object> options = baseOptions("line");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> chart = (Map<String, Object>) options.get("chart");
|
||||||
|
chart.put("sparkline", Map.of("enabled", true));
|
||||||
|
|
||||||
|
// Thinner than a full-size line, and no tooltip: a KPI tile is meant to
|
||||||
|
// be read at a glance, not hovered.
|
||||||
|
options.put("stroke", Map.of("curve", "smooth", "width", 2));
|
||||||
|
options.put("tooltip", Map.of("enabled", false));
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ public enum Fa {
|
|||||||
REMOVE("fa-solid", "fa-trash"),
|
REMOVE("fa-solid", "fa-trash"),
|
||||||
RESET("fa-solid", "fa-arrow-rotate-left"),
|
RESET("fa-solid", "fa-arrow-rotate-left"),
|
||||||
DRAG("fa-solid", "fa-grip-vertical"),
|
DRAG("fa-solid", "fa-grip-vertical"),
|
||||||
CLOSE("fa-solid", "fa-xmark");
|
CLOSE("fa-solid", "fa-xmark"),
|
||||||
|
TREND_UP("fa-solid", "fa-arrow-trend-up"),
|
||||||
|
TREND_DOWN("fa-solid", "fa-arrow-trend-down");
|
||||||
|
|
||||||
private final String[] classes;
|
private final String[] classes;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.example.components;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.text.DecimalFormatSymbols;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-chart dashboard widget: one large value with its label, an optional
|
||||||
|
* signed delta versus the previous period, and an optional
|
||||||
|
* {@link SparklineChart}.
|
||||||
|
* <p>
|
||||||
|
* Deliberately not a {@link Card}: a card contributes its own surface, heading
|
||||||
|
* and 20px padding, which is more chrome than fits a one-row grid item. The
|
||||||
|
* tile draws no surface of its own and fills the grid item's instead, styled
|
||||||
|
* from the same {@code --dialect-*} token layer (see {@code styles.css}) —
|
||||||
|
* including the delta's up/down colors, which are tokens rather than literals
|
||||||
|
* so they follow the light/dark toggle.
|
||||||
|
* <p>
|
||||||
|
* Label and value are passed in already translated; the only text this
|
||||||
|
* component formats itself is the delta, which is locale-formatted rather than
|
||||||
|
* translated (a signed number and a percent sign).
|
||||||
|
*/
|
||||||
|
public class KpiTile extends Div {
|
||||||
|
|
||||||
|
/** Positive/negative/zero delta modifiers; {@code styles.css} keys the
|
||||||
|
* delta color off these. */
|
||||||
|
private static final String DELTA_CLASS = "dialect-kpi__delta";
|
||||||
|
|
||||||
|
private final Span value = new Span();
|
||||||
|
private final Span label = new Span();
|
||||||
|
private final Div delta = new Div();
|
||||||
|
/** Value and delta share one row — stacking them costs a line the tile does
|
||||||
|
* not have at its default height of one grid cell. */
|
||||||
|
private final Div figure = new Div();
|
||||||
|
private String deltaText;
|
||||||
|
private SparklineChart sparkline;
|
||||||
|
|
||||||
|
public KpiTile(String label, String value) {
|
||||||
|
addClassName("dialect-kpi");
|
||||||
|
|
||||||
|
this.value.addClassName("dialect-kpi__value");
|
||||||
|
this.value.setText(value);
|
||||||
|
|
||||||
|
this.label.addClassName("dialect-kpi__label");
|
||||||
|
this.label.setText(label);
|
||||||
|
|
||||||
|
figure.addClassName("dialect-kpi__figure");
|
||||||
|
figure.add(this.value);
|
||||||
|
|
||||||
|
add(this.label, figure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public KpiTile setValue(String value) {
|
||||||
|
this.value.setText(value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the change versus the previous period, as a percentage: an arrow
|
||||||
|
* plus the signed number, colored by direction. A delta of exactly zero
|
||||||
|
* gets the neutral treatment rather than an upward arrow.
|
||||||
|
*/
|
||||||
|
public KpiTile setDelta(double percent) {
|
||||||
|
delta.removeAll();
|
||||||
|
delta.getClassNames().clear();
|
||||||
|
delta.addClassName(DELTA_CLASS);
|
||||||
|
delta.addClassName(DELTA_CLASS + "--" + direction(percent));
|
||||||
|
|
||||||
|
if (percent > 0) {
|
||||||
|
delta.add(Fa.TREND_UP.create());
|
||||||
|
} else if (percent < 0) {
|
||||||
|
delta.add(Fa.TREND_DOWN.create());
|
||||||
|
}
|
||||||
|
deltaText = formatDelta(percent);
|
||||||
|
delta.add(new Span(deltaText));
|
||||||
|
|
||||||
|
if (delta.getParent().isEmpty()) {
|
||||||
|
figure.add(delta);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered delta text (without the arrow icon), or {@code null} while
|
||||||
|
* no delta has been set. Kept as a field rather than read back off the
|
||||||
|
* element: the text lives in a child span, so the wrapper's own text
|
||||||
|
* content is empty. */
|
||||||
|
public String getDeltaText() {
|
||||||
|
return deltaText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a trend line below the value, or re-feeds the existing one. */
|
||||||
|
public KpiTile setSparkline(String seriesName, List<Double> values) {
|
||||||
|
if (sparkline == null) {
|
||||||
|
sparkline = new SparklineChart();
|
||||||
|
sparkline.setWidthFull();
|
||||||
|
sparkline.setHeight("100%");
|
||||||
|
|
||||||
|
Div wrapper = new Div(sparkline);
|
||||||
|
wrapper.addClassName("dialect-kpi__sparkline");
|
||||||
|
add(wrapper);
|
||||||
|
}
|
||||||
|
sparkline.setData(seriesName, values);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SparklineChart getSparkline() {
|
||||||
|
return sparkline;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String direction(double percent) {
|
||||||
|
if (percent > 0) {
|
||||||
|
return "up";
|
||||||
|
}
|
||||||
|
return percent < 0 ? "down" : "flat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code +12,4 %} / {@code -3,1 %} — the sign is always explicit, since a
|
||||||
|
* delta without one reads as an absolute value. */
|
||||||
|
private String formatDelta(double percent) {
|
||||||
|
Locale locale = getLocale();
|
||||||
|
DecimalFormat format = new DecimalFormat("+0.0;-0.0",
|
||||||
|
DecimalFormatSymbols.getInstance(locale));
|
||||||
|
return format.format(percent) + " %";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.example.components;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chrome-free line chart — no axes, grid, legend or tooltip, just the trend
|
||||||
|
* shape — meant to sit inside a {@link KpiTile} next to the value it belongs
|
||||||
|
* to. Not an {@link AxisChart}: there are no categories to label, so the
|
||||||
|
* axis-based {@code setData} signature would only carry dead arguments.
|
||||||
|
*/
|
||||||
|
public class SparklineChart extends ApexChart {
|
||||||
|
|
||||||
|
/** Marker class the axis-suppressing rule in {@code styles.css} keys off. */
|
||||||
|
private static final String CLASS_NAME = "dialect-sparkline";
|
||||||
|
|
||||||
|
public SparklineChart() {
|
||||||
|
super("line");
|
||||||
|
// Element API rather than addClassName: ApexChart is HasSize, not
|
||||||
|
// HasStyle.
|
||||||
|
getElement().getClassList().add(CLASS_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setData(String seriesName, List<Double> values) {
|
||||||
|
Map<String, Object> options = DialectTheme.sparklineOptions();
|
||||||
|
options.put("series", List.of(Map.of("name", seriesName, "data", values)));
|
||||||
|
|
||||||
|
sendOptions(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.example.components.Card;
|
|||||||
import com.example.components.Fa;
|
import com.example.components.Fa;
|
||||||
import com.example.components.GridStackItem;
|
import com.example.components.GridStackItem;
|
||||||
import com.example.components.GridStackLayout;
|
import com.example.components.GridStackLayout;
|
||||||
|
import com.example.components.KpiTile;
|
||||||
import com.example.components.LineChart;
|
import com.example.components.LineChart;
|
||||||
import com.example.components.PieChart;
|
import com.example.components.PieChart;
|
||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
@@ -44,14 +45,30 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
grid.addLayoutChangeListener(e -> status.setText(
|
grid.addLayoutChangeListener(e -> status.setText(
|
||||||
getTranslation("gridstack.status", e.getPositions().size())));
|
getTranslation("gridstack.status", e.getPositions().size())));
|
||||||
|
|
||||||
|
// KPI tiles first: the numbers a dashboard is read for, above the charts
|
||||||
|
// that explain them. They are 3x1 — a quarter row each, one cell high.
|
||||||
grid.add(
|
grid.add(
|
||||||
new GridStackItem("revenue-trend", 0, 0, 6, 3,
|
new GridStackItem("kpi-revenue", 0, 0, 3, 1,
|
||||||
|
kpiTile("kpi.revenueTotal", 12.4,
|
||||||
|
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0))),
|
||||||
|
new GridStackItem("kpi-orders", 3, 0, 3, 1,
|
||||||
|
kpiTile("kpi.openOrders", -3.1,
|
||||||
|
List.of(52.0, 47.0, 49.0, 44.0, 40.0, 38.0))),
|
||||||
|
new GridStackItem("kpi-customers", 6, 0, 3, 1,
|
||||||
|
kpiTile("kpi.newCustomers", 8.0,
|
||||||
|
List.of(74.0, 81.0, 79.0, 95.0, 104.0, 112.0))),
|
||||||
|
new GridStackItem("kpi-order-value", 9, 0, 3, 1,
|
||||||
|
kpiTile("kpi.averageOrderValue", 0.0,
|
||||||
|
List.of(480.0, 492.0, 478.0, 489.0, 483.0, 486.0))));
|
||||||
|
|
||||||
|
grid.add(
|
||||||
|
new GridStackItem("revenue-trend", 0, 1, 6, 3,
|
||||||
new Card(getTranslation("card.revenueTrend"), lineChart())),
|
new Card(getTranslation("card.revenueTrend"), lineChart())),
|
||||||
new GridStackItem("revenue-month", 6, 0, 6, 3,
|
new GridStackItem("revenue-month", 6, 1, 6, 3,
|
||||||
new Card(getTranslation("card.revenueByMonth"), barChart())),
|
new Card(getTranslation("card.revenueByMonth"), barChart())),
|
||||||
new GridStackItem("revenue-region", 0, 3, 5, 3,
|
new GridStackItem("revenue-region", 0, 4, 5, 3,
|
||||||
new Card(getTranslation("card.revenueByRegion"), pieChart())),
|
new Card(getTranslation("card.revenueByRegion"), pieChart())),
|
||||||
new GridStackItem("hint", 5, 3, 7, 3,
|
new GridStackItem("hint", 5, 4, 7, 3,
|
||||||
new Card(getTranslation("card.gridstackHint"),
|
new Card(getTranslation("card.gridstackHint"),
|
||||||
new Paragraph(getTranslation("gridstack.hint")))));
|
new Paragraph(getTranslation("gridstack.hint")))));
|
||||||
|
|
||||||
@@ -88,6 +105,16 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
|
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The displayed value comes from the bundle alongside the label, since it
|
||||||
|
* carries locale-specific formatting (decimal separator, currency, "Mio.").
|
||||||
|
* Like the chart literals above, it moves to the data service in #20. */
|
||||||
|
private KpiTile kpiTile(String labelKey, double delta, List<Double> trend) {
|
||||||
|
String label = getTranslation(labelKey);
|
||||||
|
return new KpiTile(label, getTranslation(labelKey + ".value"))
|
||||||
|
.setDelta(delta)
|
||||||
|
.setSparkline(label, trend);
|
||||||
|
}
|
||||||
|
|
||||||
private Component lineChart() {
|
private Component lineChart() {
|
||||||
LineChart chart = new LineChart();
|
LineChart chart = new LineChart();
|
||||||
chart.setData(getTranslation("chart.revenueSeries"),
|
chart.setData(getTranslation("chart.revenueSeries"),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
--dialect-ink: light-dark(#14171f, #e6eaf0);
|
--dialect-ink: light-dark(#14171f, #e6eaf0);
|
||||||
--dialect-border: light-dark(#e6eaf0, #2b303b);
|
--dialect-border: light-dark(#e6eaf0, #2b303b);
|
||||||
--dialect-radius: 14px;
|
--dialect-radius: 14px;
|
||||||
|
/* Delta direction (KpiTile). Lightened in dark mode so they keep contrast
|
||||||
|
against the dark surface instead of going muddy. */
|
||||||
|
--dialect-positive: light-dark(#1f9d63, #45c98a);
|
||||||
|
--dialect-negative: light-dark(#d1453b, #f0736a);
|
||||||
/* light-dark() only takes 2 args, so each shadow layer gets its own light-dark()
|
/* light-dark() only takes 2 args, so each shadow layer gets its own light-dark()
|
||||||
call and the layers are joined below (var() is a text substitution, so the
|
call and the layers are joined below (var() is a text substitution, so the
|
||||||
comma-separated shadow list still parses correctly). */
|
comma-separated shadow list still parses correctly). */
|
||||||
@@ -123,6 +127,108 @@ vaadin-app-layout::part(content) {
|
|||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KpiTile (components/KpiTile.java): a one-row widget, so everything is sized
|
||||||
|
to survive a 120px grid cell — the sparkline is the only flexible part and
|
||||||
|
collapses first. */
|
||||||
|
.dialect-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* border-box, or the padding is added on top of the 100% and the tile ends
|
||||||
|
up taller than the grid item it is supposed to fill. */
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Value and delta on one line, baseline-aligned, so a one-cell-high tile still
|
||||||
|
has room for the sparkline underneath. */
|
||||||
|
.dialect-kpi__figure {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-kpi__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dialect-ink);
|
||||||
|
opacity: 0.6;
|
||||||
|
/* Long labels shorten rather than wrapping into a second line, which at
|
||||||
|
h=1 would push the value out of the tile. */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-kpi__value {
|
||||||
|
font-size: clamp(1.4rem, 4cqw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--dialect-ink);
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Never shrinks: a truncated delta would be unreadable, so the value gives way
|
||||||
|
first (it ellipsises). */
|
||||||
|
.dialect-kpi__delta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-kpi__delta--up {
|
||||||
|
color: var(--dialect-positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-kpi__delta--down {
|
||||||
|
color: var(--dialect-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-kpi__delta--flat {
|
||||||
|
color: var(--dialect-ink);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ApexCharts' `sparkline.enabled` drops the axes on the initial render, but a
|
||||||
|
later updateOptions — which is how the theme toggle recolors a chart — brings
|
||||||
|
the y-axis labels back, clipped to a smudge at the tile's left edge. Hide
|
||||||
|
them for good; the chart renders into light DOM, so this rule reaches its SVG
|
||||||
|
(see components/apex-chart.ts). */
|
||||||
|
apex-chart.dialect-sparkline .apexcharts-yaxis,
|
||||||
|
apex-chart.dialect-sparkline .apexcharts-xaxis {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The only part allowed to shrink: at h=1 there is no room left for it and it
|
||||||
|
collapses to nothing, at h≥2 it takes the slack. */
|
||||||
|
.dialect-kpi__sparkline {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tile draws no surface of its own — inside a grid item it fills
|
||||||
|
grid-stack-item-content, which already provides background, radius and
|
||||||
|
shadow. This also gives the value's `cqw` font size a container to resolve
|
||||||
|
against, so a narrow tile scales its number down instead of clipping it. */
|
||||||
|
.grid-stack-item-content > .dialect-kpi {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
/* Secondary text (e.g. the layout status line in GridStackView). */
|
/* Secondary text (e.g. the layout status line in GridStackView). */
|
||||||
.dialect-muted {
|
.dialect-muted {
|
||||||
color: var(--dialect-ink);
|
color: var(--dialect-ink);
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ gridstack.widgetText=Frei platzierbare Karte.
|
|||||||
gridstack.dragHandle=Verschieben
|
gridstack.dragHandle=Verschieben
|
||||||
gridstack.close=Schließen
|
gridstack.close=Schließen
|
||||||
|
|
||||||
|
kpi.revenueTotal=Gesamtumsatz
|
||||||
|
kpi.revenueTotal.value=1,24 Mio. €
|
||||||
|
kpi.openOrders=Offene Vorgänge
|
||||||
|
kpi.openOrders.value=38
|
||||||
|
kpi.newCustomers=Neue Kunden
|
||||||
|
kpi.newCustomers.value=112
|
||||||
|
kpi.averageOrderValue=Ø Bestellwert
|
||||||
|
kpi.averageOrderValue.value=486 €
|
||||||
|
|
||||||
chart.revenueSeries=Umsatz 2026
|
chart.revenueSeries=Umsatz 2026
|
||||||
chart.pointClick=Serie {0}, Punkt {1}
|
chart.pointClick=Serie {0}, Punkt {1}
|
||||||
chart.sliceClick=Slice {0}
|
chart.sliceClick=Slice {0}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ gridstack.widgetText=Freely placeable card.
|
|||||||
gridstack.dragHandle=Move
|
gridstack.dragHandle=Move
|
||||||
gridstack.close=Close
|
gridstack.close=Close
|
||||||
|
|
||||||
|
kpi.revenueTotal=Total revenue
|
||||||
|
kpi.revenueTotal.value=€1.24M
|
||||||
|
kpi.openOrders=Open orders
|
||||||
|
kpi.openOrders.value=38
|
||||||
|
kpi.newCustomers=New customers
|
||||||
|
kpi.newCustomers.value=112
|
||||||
|
kpi.averageOrderValue=Avg. order value
|
||||||
|
kpi.averageOrderValue.value=€486
|
||||||
|
|
||||||
chart.revenueSeries=Revenue 2026
|
chart.revenueSeries=Revenue 2026
|
||||||
chart.pointClick=Series {0}, point {1}
|
chart.pointClick=Series {0}, point {1}
|
||||||
chart.sliceClick=Slice {0}
|
chart.sliceClick=Slice {0}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ gridstack.widgetText=Tarjeta de colocación libre.
|
|||||||
gridstack.dragHandle=Mover
|
gridstack.dragHandle=Mover
|
||||||
gridstack.close=Cerrar
|
gridstack.close=Cerrar
|
||||||
|
|
||||||
|
kpi.revenueTotal=Ingresos totales
|
||||||
|
kpi.revenueTotal.value=1,24 M€
|
||||||
|
kpi.openOrders=Pedidos abiertos
|
||||||
|
kpi.openOrders.value=38
|
||||||
|
kpi.newCustomers=Nuevos clientes
|
||||||
|
kpi.newCustomers.value=112
|
||||||
|
kpi.averageOrderValue=Valor medio del pedido
|
||||||
|
kpi.averageOrderValue.value=486 €
|
||||||
|
|
||||||
chart.revenueSeries=Ingresos 2026
|
chart.revenueSeries=Ingresos 2026
|
||||||
chart.pointClick=Serie {0}, punto {1}
|
chart.pointClick=Serie {0}, punto {1}
|
||||||
chart.sliceClick=Sector {0}
|
chart.sliceClick=Sector {0}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.example.Application;
|
|||||||
import com.example.components.BarChart;
|
import com.example.components.BarChart;
|
||||||
import com.example.components.GridStackItem;
|
import com.example.components.GridStackItem;
|
||||||
import com.example.components.GridStackLayout;
|
import com.example.components.GridStackLayout;
|
||||||
|
import com.example.components.KpiTile;
|
||||||
import com.example.components.LineChart;
|
import com.example.components.LineChart;
|
||||||
import com.example.components.PieChart;
|
import com.example.components.PieChart;
|
||||||
import com.vaadin.browserless.SpringBrowserlessTest;
|
import com.vaadin.browserless.SpringBrowserlessTest;
|
||||||
@@ -27,6 +28,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
@ViewPackages(classes = DashboardView.class)
|
@ViewPackages(classes = DashboardView.class)
|
||||||
class DashboardViewTest extends SpringBrowserlessTest {
|
class DashboardViewTest extends SpringBrowserlessTest {
|
||||||
|
|
||||||
|
/** Four KPI tiles plus three charts and the hint card. */
|
||||||
|
private static final int DEFAULT_WIDGETS = 8;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void view_rendersGridWithDefaultWidgets() {
|
void view_rendersGridWithDefaultWidgets() {
|
||||||
navigate(DashboardView.class);
|
navigate(DashboardView.class);
|
||||||
@@ -35,10 +39,42 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
assertNotNull(grid);
|
assertNotNull(grid);
|
||||||
|
|
||||||
List<GridStackItem.Position> layout = grid.getLayout();
|
List<GridStackItem.Position> layout = grid.getLayout();
|
||||||
assertEquals(4, layout.size(), "expected the four default widgets");
|
assertEquals(DEFAULT_WIDGETS, layout.size(), "expected the default widgets");
|
||||||
assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
|
assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kpiTiles_renderValueAndDelta() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
|
||||||
|
List<KpiTile> tiles = $view(KpiTile.class).all();
|
||||||
|
assertEquals(4, tiles.size(), "expected the four KPI tiles");
|
||||||
|
|
||||||
|
KpiTile revenue = tiles.getFirst();
|
||||||
|
assertEquals(translate("kpi.revenueTotal"), revenue.getLabel());
|
||||||
|
assertEquals(translate("kpi.revenueTotal.value"), revenue.getValue());
|
||||||
|
// +12.4 — sign is explicit, decimal separator is the locale's.
|
||||||
|
assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"),
|
||||||
|
"unexpected delta text: " + revenue.getDeltaText());
|
||||||
|
|
||||||
|
KpiTile orders = tiles.get(1);
|
||||||
|
assertTrue(orders.getDeltaText().startsWith("-"),
|
||||||
|
"a negative delta keeps its minus sign: " + orders.getDeltaText());
|
||||||
|
assertNotNull(orders.getSparkline(), "each tile carries a sparkline");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kpiTiles_areOneRowHigh() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
|
||||||
|
List<GridStackItem.Position> kpis = $view(GridStackLayout.class).first().getLayout()
|
||||||
|
.stream().filter(p -> p.id().startsWith("kpi-")).toList();
|
||||||
|
|
||||||
|
assertEquals(4, kpis.size());
|
||||||
|
assertTrue(kpis.stream().allMatch(p -> p.w() == 3 && p.h() == 1),
|
||||||
|
"KPI tiles default to a quarter row, one cell high");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void view_rendersTheThreeCharts() {
|
void view_rendersTheThreeCharts() {
|
||||||
navigate(DashboardView.class);
|
navigate(DashboardView.class);
|
||||||
@@ -73,7 +109,7 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
clickCloseButton(first);
|
clickCloseButton(first);
|
||||||
|
|
||||||
List<GridStackItem.Position> layout = grid.getLayout();
|
List<GridStackItem.Position> layout = grid.getLayout();
|
||||||
assertEquals(3, layout.size());
|
assertEquals(DEFAULT_WIDGETS - 1, layout.size());
|
||||||
assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())),
|
assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())),
|
||||||
"the closed widget must be gone from the layout");
|
"the closed widget must be gone from the layout");
|
||||||
}
|
}
|
||||||
@@ -90,7 +126,7 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
item.close();
|
item.close();
|
||||||
|
|
||||||
assertEquals(1, fired[0]);
|
assertEquals(1, fired[0]);
|
||||||
assertEquals(3, grid.getLayout().size());
|
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,8 +150,11 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Button button(String translationKey) {
|
private Button button(String translationKey) {
|
||||||
String caption = getCurrentView().getElement().getComponent()
|
return $view(Button.class).withText(translate(translationKey)).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String translate(String translationKey) {
|
||||||
|
return getCurrentView().getElement().getComponent()
|
||||||
.orElseThrow().getTranslation(translationKey);
|
.orElseThrow().getTranslation(translationKey);
|
||||||
return $view(Button.class).withText(caption).first();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user