diff --git a/src/main/java/com/example/data/ChartDataService.java b/src/main/java/com/example/data/ChartDataService.java new file mode 100644 index 0000000..6e874d5 --- /dev/null +++ b/src/main/java/com/example/data/ChartDataService.java @@ -0,0 +1,25 @@ +package com.example.data; + +import java.util.List; + +/** + * The seam between the dashboard widgets and wherever their numbers come from. + * Views ask this service for data instead of holding literals, so a widget can + * be re-fed later (live refresh, a global filter, a per-widget refresh action) + * and so the data logic is testable without a Vaadin component tree. + *

+ * Deliberately thin: the current implementation + * ({@link InMemoryChartDataService}) is in-memory dummy data. A real backend + * would replace the implementation, not this interface. + */ +public interface ChartDataService { + + /** Revenue per month, for the line and bar charts. */ + ChartSeries revenueByMonth(); + + /** Revenue per sales region, for the pie chart. */ + ChartSeries revenueByRegion(); + + /** The KPI tiles, in the order the dashboard lays them out. */ + List kpis(); +} diff --git a/src/main/java/com/example/data/ChartSeries.java b/src/main/java/com/example/data/ChartSeries.java new file mode 100644 index 0000000..b29c3e1 --- /dev/null +++ b/src/main/java/com/example/data/ChartSeries.java @@ -0,0 +1,27 @@ +package com.example.data; + +import java.util.List; + +/** + * One named series of numbers plus the categories they are indexed by — the + * shape every chart on the dashboard is fed with, axis-based or not (a pie + * chart reads the categories as slice labels and ignores the series name). + *

+ * {@code nameKey} and {@code categoryKeys} are translation keys, not + * display text: the data layer must stay free of a {@code UI} and its locale, + * so resolving them against the bundle is the view's job. Values and keys are + * defensively copied, so a data set handed out by a + * {@link ChartDataService} cannot be modified by its consumer. + */ +public record ChartSeries(String nameKey, List values, List categoryKeys) { + + public ChartSeries { + values = List.copyOf(values); + categoryKeys = List.copyOf(categoryKeys); + if (values.size() != categoryKeys.size()) { + throw new IllegalArgumentException( + "each value needs a category: %d values, %d categories" + .formatted(values.size(), categoryKeys.size())); + } + } +} diff --git a/src/main/java/com/example/data/InMemoryChartDataService.java b/src/main/java/com/example/data/InMemoryChartDataService.java new file mode 100644 index 0000000..34f1703 --- /dev/null +++ b/src/main/java/com/example/data/InMemoryChartDataService.java @@ -0,0 +1,51 @@ +package com.example.data; + +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Dummy implementation returning the hardcoded numbers the views used to carry + * inline. Everything is a constant, so each call returns the same data — the + * point of the service is the seam, not a persistence layer. + */ +@Service +public class InMemoryChartDataService implements ChartDataService { + + private static final List MONTHS = List.of( + "month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun"); + + private static final List REGIONS = List.of( + "region.north", "region.south", "region.east", "region.west"); + + private static final ChartSeries REVENUE_BY_MONTH = new ChartSeries( + "chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), MONTHS); + + private static final ChartSeries REVENUE_BY_REGION = new ChartSeries( + "chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0), REGIONS); + + private static final List KPIS = List.of( + new KpiData("kpi-revenue", "kpi.revenueTotal", 12.4, + List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0)), + new KpiData("kpi-orders", "kpi.openOrders", -3.1, + List.of(52.0, 47.0, 49.0, 44.0, 40.0, 38.0)), + new KpiData("kpi-customers", "kpi.newCustomers", 8.0, + List.of(74.0, 81.0, 79.0, 95.0, 104.0, 112.0)), + new KpiData("kpi-order-value", "kpi.averageOrderValue", 0.0, + List.of(480.0, 492.0, 478.0, 489.0, 483.0, 486.0))); + + @Override + public ChartSeries revenueByMonth() { + return REVENUE_BY_MONTH; + } + + @Override + public ChartSeries revenueByRegion() { + return REVENUE_BY_REGION; + } + + @Override + public List kpis() { + return KPIS; + } +} diff --git a/src/main/java/com/example/data/KpiData.java b/src/main/java/com/example/data/KpiData.java new file mode 100644 index 0000000..1c97ce4 --- /dev/null +++ b/src/main/java/com/example/data/KpiData.java @@ -0,0 +1,25 @@ +package com.example.data; + +import java.util.List; + +/** + * The numbers behind one KPI tile: the change versus the previous period and + * the trend the sparkline draws. + *

+ * {@code id} identifies the KPI itself (the dashboard reuses it as the grid + * item's {@code gs-id}, which must stay stable across reloads for a saved + * layout to match it again), while {@code labelKey} is a translation key — the + * displayed value is looked up as {@code labelKey + ".value"}, because it + * carries locale-specific formatting (decimal separator, currency, "Mio."). + */ +public record KpiData(String id, String labelKey, double deltaPercent, List trend) { + + public KpiData { + trend = List.copyOf(trend); + } + + /** The bundle key of the pre-formatted display value. */ + public String valueKey() { + return labelKey + ".value"; + } +} diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 1a95826..0c1edf2 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -1,6 +1,7 @@ package com.example.views; import com.example.components.ApexChart; +import com.example.components.AxisChart; import com.example.components.BarChart; import com.example.components.Card; import com.example.components.Fa; @@ -9,6 +10,9 @@ import com.example.components.GridStackLayout; import com.example.components.KpiTile; import com.example.components.LineChart; import com.example.components.PieChart; +import com.example.data.ChartDataService; +import com.example.data.ChartSeries; +import com.example.data.KpiData; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; @@ -27,17 +31,26 @@ import java.util.List; * layout is persisted per browser, plus controls to add widgets and reset the * layout at runtime. Widgets are removed by their own close button (see * {@link GridStackItem#setClosable(boolean)}), not from the toolbar. + *

+ * All widget numbers come from {@link ChartDataService}; this view only decides + * where a widget sits and resolves the data's translation keys against the + * bundle. */ @Route("") public class DashboardView extends VerticalLayout implements HasDynamicTitle { private static final String STORAGE_KEY = "dashboard"; + /** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */ + private static final int KPI_WIDTH = 3; + + private final ChartDataService dataService; private final GridStackLayout grid = new GridStackLayout(); private final Span status = new Span(); private int extraWidgetCount; - public DashboardView() { + public DashboardView(ChartDataService dataService) { + this.dataService = dataService; addClassName("dialect-content"); grid.setWidthFull(); @@ -46,20 +59,15 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { 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( - 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)))); + // that explain them. They are 3x1 — a quarter row each, one cell high, + // laid out left to right in the order the service returns them. The + // grid id is the KPI's own id, so it survives reordering. + List kpis = dataService.kpis(); + for (int i = 0; i < kpis.size(); i++) { + KpiData kpi = kpis.get(i); + grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, + kpiTile(kpi))); + } grid.add( new GridStackItem("revenue-trend", 0, 1, 6, 3, @@ -106,38 +114,37 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { } /** 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 trend) { - String label = getTranslation(labelKey); - return new KpiTile(label, getTranslation(labelKey + ".value")) - .setDelta(delta) - .setSparkline(label, trend); + * carries locale-specific formatting (decimal separator, currency, "Mio."); + * the service supplies the key, not the formatted text. */ + private KpiTile kpiTile(KpiData kpi) { + String label = getTranslation(kpi.labelKey()); + return new KpiTile(label, getTranslation(kpi.valueKey())) + .setDelta(kpi.deltaPercent()) + .setSparkline(label, kpi.trend()); } private Component lineChart() { - LineChart chart = new LineChart(); - chart.setData(getTranslation("chart.revenueSeries"), - List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months()); - chart.addPointClickListener(e -> Notification.show(getTranslation( - "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); - return sizeFull(chart); + return axisChart(new LineChart()); } private Component barChart() { - BarChart chart = new BarChart(); - chart.setData(getTranslation("chart.revenueSeries"), - List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months()); + return axisChart(new BarChart()); + } + + private Component axisChart(AxisChart chart) { + ChartSeries series = dataService.revenueByMonth(); + chart.setData(getTranslation(series.nameKey()), series.values(), + translate(series.categoryKeys())); chart.addPointClickListener(e -> Notification.show(getTranslation( "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); return sizeFull(chart); } private Component pieChart() { + ChartSeries series = dataService.revenueByRegion(); PieChart chart = new PieChart(); - chart.setData(List.of(30.0, 40.0, 35.0, 50.0), - List.of(getTranslation("region.north"), getTranslation("region.south"), - getTranslation("region.east"), getTranslation("region.west"))); + // A pie has no series name — its categories are the slice labels. + chart.setData(series.values(), translate(series.categoryKeys())); chart.addPointClickListener(e -> Notification.show( getTranslation("chart.sliceClick", e.getDataPointIndex()))); return sizeFull(chart); @@ -152,11 +159,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { return chart; } - private List months() { - return List.of( - getTranslation("month.jan"), getTranslation("month.feb"), - getTranslation("month.mar"), getTranslation("month.apr"), - getTranslation("month.may"), getTranslation("month.jun")); + /** Data sets carry translation keys, not display text — see + * {@link ChartSeries}. */ + private List translate(List keys) { + return keys.stream().map(this::getTranslation).toList(); } @Override diff --git a/src/test/java/com/example/data/InMemoryChartDataServiceTest.java b/src/test/java/com/example/data/InMemoryChartDataServiceTest.java new file mode 100644 index 0000000..5ec95a3 --- /dev/null +++ b/src/test/java/com/example/data/InMemoryChartDataServiceTest.java @@ -0,0 +1,73 @@ +package com.example.data; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The data layer is plain Java: no Spring context, no Vaadin UI. */ +class InMemoryChartDataServiceTest { + + private final ChartDataService service = new InMemoryChartDataService(); + + @Test + void revenueByMonth_hasOneValuePerMonth() { + ChartSeries series = service.revenueByMonth(); + + assertEquals(List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), series.values()); + assertEquals(List.of("month.jan", "month.feb", "month.mar", + "month.apr", "month.may", "month.jun"), series.categoryKeys()); + assertEquals("chart.revenueSeries", series.nameKey()); + } + + @Test + void revenueByRegion_hasOneValuePerRegion() { + ChartSeries series = service.revenueByRegion(); + + assertEquals(4, series.values().size()); + assertEquals(List.of("region.north", "region.south", + "region.east", "region.west"), series.categoryKeys()); + } + + @Test + void kpis_haveStableUniqueIdsAndATrend() { + List kpis = service.kpis(); + + assertEquals(4, kpis.size()); + Set ids = kpis.stream().map(KpiData::id).collect(Collectors.toSet()); + assertEquals(kpis.size(), ids.size(), "grid ids must be unique"); + assertTrue(ids.stream().allMatch(id -> id.startsWith("kpi-"))); + assertTrue(kpis.stream().allMatch(kpi -> !kpi.trend().isEmpty()), + "each tile draws a sparkline"); + } + + @Test + void kpi_derivesTheValueKeyFromTheLabelKey() { + KpiData revenue = service.kpis().getFirst(); + + assertEquals("kpi.revenueTotal", revenue.labelKey()); + assertEquals("kpi.revenueTotal.value", revenue.valueKey()); + assertEquals(12.4, revenue.deltaPercent()); + } + + @Test + void series_isImmutableAndBalanced() { + ChartSeries series = service.revenueByMonth(); + + assertThrows(UnsupportedOperationException.class, () -> series.values().add(1.0)); + assertThrows(IllegalArgumentException.class, + () -> new ChartSeries("x", List.of(1.0, 2.0), List.of("only.one"))); + } + + @Test + void callsAreRepeatable() { + assertEquals(service.revenueByMonth(), service.revenueByMonth()); + assertFalse(service.kpis().isEmpty()); + } +} diff --git a/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java b/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java index dc1af3a..c7641e2 100644 --- a/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java +++ b/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java @@ -26,7 +26,7 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase { @Test void dataUpdate_keepsTheRenderedSvgInPlace() { - Locator chart = page.locator("apex-chart").first(); + Locator chart = axisChart(); assertThat(chart.locator("svg").first()).isVisible(); // Tag the live SVG node: a full rebuild would replace it and drop the tag. @@ -43,7 +43,7 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase { @Test void dataUpdate_keepsTheThemeOverlay() { - Locator chart = page.locator("apex-chart").first(); + Locator chart = axisChart(); assertThat(chart.locator("svg").first()).isVisible(); chart.evaluate("(el, patch) => el.updateData(patch)", PATCH); @@ -55,4 +55,12 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase { assertTrue(String.valueOf(borderColor).startsWith("rgb"), "expected a resolved theme color, got: " + borderColor); } + + /** The first chart in the DOM is a KPI sparkline, which has no + * {@code xaxis.categories} — patching one with categories is a structural + * change and legitimately redraws. The patch above is an axis-chart patch, + * so it has to be applied to an axis chart. */ + private Locator axisChart() { + return page.locator("apex-chart:not(.dialect-sparkline)").first(); + } }