From df5d1a9b9b74743402e97638f0140ec589b656ac Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Tue, 28 Jul 2026 20:52:33 +0200 Subject: [PATCH] fix: add a global dashboard filter bar (#24) The reporting period was baked into the data service, so nothing could change what the dashboard shows without editing code. - DashboardFilter (period + optional region) parameterises every ChartDataService query; the no-arg overloads are the default filter. - DashboardContext is the bus between the new DashboardFilterBar and the widgets: charts subscribe in WidgetRegistry, KPI tiles in DashboardView. - Updates go through updateData, so widgets patch in place instead of being rebuilt; SparklineChart gained the same path. - KpiData carries the number, the bundle the unit and number pattern, so a KPI value can follow the filter and stay locale-formatted. - Filter state is deliberately not persisted, unlike the grid layout. Closes #24 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69 --- .../java/com/example/components/KpiTile.java | 5 +- .../example/components/SparklineChart.java | 14 ++ .../com/example/data/ChartDataService.java | 28 +++- .../com/example/data/DashboardFilter.java | 72 +++++++++ .../data/InMemoryChartDataService.java | 137 +++++++++++++++--- src/main/java/com/example/data/KpiData.java | 18 ++- .../com/example/views/DashboardFilterBar.java | 63 ++++++++ .../java/com/example/views/DashboardView.java | 54 +++++-- .../com/example/widgets/DashboardContext.java | 63 ++++++++ .../com/example/widgets/WidgetDefinition.java | 11 +- .../com/example/widgets/WidgetRegistry.java | 53 +++++-- .../vaadin-i18n/translations.properties | 29 +++- .../vaadin-i18n/translations_en.properties | 29 +++- .../vaadin-i18n/translations_es.properties | 29 +++- .../components/ApexChartUpdateTest.java | 25 ++++ .../data/InMemoryChartDataServiceTest.java | 67 +++++++++ .../com/example/views/DashboardViewTest.java | 74 +++++++++- .../example/widgets/DashboardContextTest.java | 73 ++++++++++ .../example/widgets/WidgetRegistryTest.java | 2 +- 19 files changed, 762 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/example/data/DashboardFilter.java create mode 100644 src/main/java/com/example/views/DashboardFilterBar.java create mode 100644 src/main/java/com/example/widgets/DashboardContext.java create mode 100644 src/test/java/com/example/widgets/DashboardContextTest.java diff --git a/src/main/java/com/example/components/KpiTile.java b/src/main/java/com/example/components/KpiTile.java index e01d5e8..cc19c61 100644 --- a/src/main/java/com/example/components/KpiTile.java +++ b/src/main/java/com/example/components/KpiTile.java @@ -100,7 +100,8 @@ public class KpiTile extends Div { return deltaText; } - /** Adds a trend line below the value, or re-feeds the existing one. */ + /** Adds a trend line below the value, or re-feeds the existing one — in + * place, so a re-fed tile (a filter change) animates instead of flashing. */ public KpiTile setSparkline(String seriesName, List values) { if (sparkline == null) { sparkline = new SparklineChart(); @@ -111,7 +112,7 @@ public class KpiTile extends Div { wrapper.addClassName("dialect-kpi__sparkline"); add(wrapper); } - sparkline.setData(seriesName, values); + sparkline.updateData(seriesName, values); return this; } diff --git a/src/main/java/com/example/components/SparklineChart.java b/src/main/java/com/example/components/SparklineChart.java index 54e9d90..7e2148d 100644 --- a/src/main/java/com/example/components/SparklineChart.java +++ b/src/main/java/com/example/components/SparklineChart.java @@ -27,4 +27,18 @@ public class SparklineChart extends ApexChart { sendOptions(options); } + + /** + * Replaces the trend of an already rendered sparkline without re-rendering + * it — see {@link AxisChart#updateData}. There are no categories to patch + * along, so this is a series-only update. + */ + public void updateData(String seriesName, List values) { + if (!hasSentOptions()) { + setData(seriesName, values); + return; + } + + sendDataPatch(Map.of("series", List.of(Map.of("name", seriesName, "data", values)))); + } } diff --git a/src/main/java/com/example/data/ChartDataService.java b/src/main/java/com/example/data/ChartDataService.java index 6e874d5..fda84d5 100644 --- a/src/main/java/com/example/data/ChartDataService.java +++ b/src/main/java/com/example/data/ChartDataService.java @@ -8,18 +8,36 @@ import java.util.List; * 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. *

+ * Every query is parameterised by a {@link DashboardFilter}: that is the path + * the filter bar drives all widgets through. The no-argument overloads are the + * same queries under {@link DashboardFilter#defaults()}. + *

* 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 month (per week for {@code Period.MONTH}), for the line and + * bar charts. Honours both the period and a region selection. */ + ChartSeries revenueByMonth(DashboardFilter filter); - /** Revenue per sales region, for the pie chart. */ - ChartSeries revenueByRegion(); + /** Revenue per sales region, for the pie chart. Honours the period; a + * region selection is ignored, since the breakdown is the chart itself. */ + ChartSeries revenueByRegion(DashboardFilter filter); /** The KPI tiles, in the order the dashboard lays them out. */ - List kpis(); + List kpis(DashboardFilter filter); + + default ChartSeries revenueByMonth() { + return revenueByMonth(DashboardFilter.defaults()); + } + + default ChartSeries revenueByRegion() { + return revenueByRegion(DashboardFilter.defaults()); + } + + default List kpis() { + return kpis(DashboardFilter.defaults()); + } } diff --git a/src/main/java/com/example/data/DashboardFilter.java b/src/main/java/com/example/data/DashboardFilter.java new file mode 100644 index 0000000..94b5b66 --- /dev/null +++ b/src/main/java/com/example/data/DashboardFilter.java @@ -0,0 +1,72 @@ +package com.example.data; + +import java.util.Objects; + +/** + * What the dashboard is currently showing: a reporting period and, optionally, a + * single sales region. Immutable — a filter change produces a new instance + * (see {@link #withPeriod} / {@link #withRegion}), which is what makes it safe + * to hand around to widgets. + *

+ * Every {@link ChartDataService} query takes one of these; widgets a given + * dimension does not apply to simply ignore it (the region pie chart is + * the region breakdown, so it ignores a region selection but honours the + * period). + */ +public record DashboardFilter(Period period, String regionKey) { + + /** + * Sentinel {@link #regionKey} meaning "do not restrict by region". It is a + * translation key of its own, so the filter bar can label it like any real + * region instead of special-casing a null. + */ + public static final String ALL_REGIONS = "filter.allRegions"; + + public DashboardFilter { + Objects.requireNonNull(period, "period"); + Objects.requireNonNull(regionKey, "regionKey"); + } + + /** The unfiltered dashboard: half a year, all regions. */ + public static DashboardFilter defaults() { + return new DashboardFilter(Period.HALF_YEAR, ALL_REGIONS); + } + + public boolean allRegions() { + return ALL_REGIONS.equals(regionKey); + } + + public DashboardFilter withPeriod(Period period) { + return new DashboardFilter(period, regionKey); + } + + public DashboardFilter withRegion(String regionKey) { + return new DashboardFilter(period, regionKey); + } + + /** + * The reporting period, which decides both the range covered and the + * granularity of a data point: a month is read as four weeks, everything + * longer as months. + */ + public enum Period { + + MONTH("filter.period.month"), + QUARTER("filter.period.quarter"), + HALF_YEAR("filter.period.halfYear"), + YEAR("filter.period.year"); + + private final String labelKey; + + Period(String labelKey) { + this.labelKey = labelKey; + } + + /** Translation key of the caption the filter bar shows — the data layer + * stays free of a {@code UI} and its locale, same as + * {@link ChartSeries}. */ + public String labelKey() { + return labelKey; + } + } +} diff --git a/src/main/java/com/example/data/InMemoryChartDataService.java b/src/main/java/com/example/data/InMemoryChartDataService.java index 34f1703..1d1f5f5 100644 --- a/src/main/java/com/example/data/InMemoryChartDataService.java +++ b/src/main/java/com/example/data/InMemoryChartDataService.java @@ -1,51 +1,142 @@ package com.example.data; +import com.example.data.DashboardFilter.Period; 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. + * Dummy implementation over a hardcoded year of numbers. There is no query + * engine behind this: a {@link DashboardFilter} selects a slice of the base + * year and scales it, deterministically, so the same filter always yields the + * same data. + *

+ * The scaling is relative to {@link DashboardFilter#defaults()}, so the + * unfiltered dashboard shows exactly the figures it always did. */ @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"); + "month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun", + "month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec"); + + /** The four weeks a {@code Period.MONTH} is split into. */ + private static final List WEEKS = List.of( + "week.1", "week.2", "week.3", "week.4"); + + /** How a month's figure is distributed over its weeks. Sums to 1, so a + * month's four weeks add up to the month again. */ + private static final List WEEK_SHARES = List.of(0.22, 0.25, 0.23, 0.30); + + /** Index of the month a {@code Period.MONTH} reports on — June, the last + * month of the default half year. */ + private static final int CURRENT_MONTH = 5; 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); + /** Revenue per region over the default half year; also the weights a region + * selection scales the other widgets by. */ + private static final List REVENUE_PER_REGION = List.of(30.0, 40.0, 35.0, 50.0); - 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 REVENUE_PER_MONTH = List.of( + 30.0, 40.0, 35.0, 50.0, 49.0, 60.0, 58.0, 64.0, 55.0, 70.0, 66.0, 80.0); - 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))); + private static final List OPEN_ORDERS_TREND = List.of( + 52.0, 47.0, 49.0, 44.0, 40.0, 38.0, 41.0, 39.0, 36.0, 34.0, 33.0, 30.0); + + private static final List NEW_CUSTOMERS_TREND = List.of( + 74.0, 81.0, 79.0, 95.0, 104.0, 112.0, 108.0, 118.0, 121.0, 130.0, 127.0, 140.0); + + private static final List ORDER_VALUE_TREND = List.of( + 480.0, 492.0, 478.0, 489.0, 483.0, 486.0, 491.0, 488.0, 494.0, 490.0, 497.0, 499.0); @Override - public ChartSeries revenueByMonth() { - return REVENUE_BY_MONTH; + public ChartSeries revenueByMonth(DashboardFilter filter) { + return new ChartSeries("chart.revenueSeries", + scale(slice(REVENUE_PER_MONTH, filter.period()), regionShare(filter), 1), + categoryKeys(filter.period())); } @Override - public ChartSeries revenueByRegion() { - return REVENUE_BY_REGION; + public ChartSeries revenueByRegion(DashboardFilter filter) { + // The region breakdown is what this chart is, so a region selection + // would leave it with a single slice — it honours the period only. + return new ChartSeries("chart.revenueSeries", + scale(REVENUE_PER_REGION, periodFactor(filter.period()), 1), REGIONS); } @Override - public List kpis() { - return KPIS; + public List kpis(DashboardFilter filter) { + double region = regionShare(filter); + // A total covers the whole period, so it grows with it; a snapshot + // (open orders) and an average (order value) do not. + double total = periodFactor(filter.period()) * region; + + return List.of( + new KpiData("kpi-revenue", "kpi.revenueTotal", round(1.24 * total, 2), 12.4, + scale(slice(REVENUE_PER_MONTH, filter.period()), region, 1)), + new KpiData("kpi-orders", "kpi.openOrders", round(38 * region, 0), -3.1, + scale(slice(OPEN_ORDERS_TREND, filter.period()), region, 0)), + new KpiData("kpi-customers", "kpi.newCustomers", round(112 * total, 0), 8.0, + scale(slice(NEW_CUSTOMERS_TREND, filter.period()), region, 0)), + new KpiData("kpi-order-value", "kpi.averageOrderValue", 486.0, 0.0, + slice(ORDER_VALUE_TREND, filter.period()))); + } + + /** The stretch of the base year a period covers, at the period's own + * granularity: four weeks of the current month, or whole months. */ + private static List slice(List year, Period period) { + return switch (period) { + case MONTH -> WEEK_SHARES.stream() + .map(share -> round(year.get(CURRENT_MONTH) * share, 1)).toList(); + case QUARTER -> List.copyOf(year.subList(3, 6)); + case HALF_YEAR -> List.copyOf(year.subList(0, 6)); + case YEAR -> year; + }; + } + + private static List categoryKeys(Period period) { + return switch (period) { + case MONTH -> WEEKS; + case QUARTER -> MONTHS.subList(3, 6); + case HALF_YEAR -> MONTHS.subList(0, 6); + case YEAR -> MONTHS; + }; + } + + /** How much larger the selected period is than the default half year — what + * cumulative figures (pie slices, KPI totals) are scaled by. */ + private static double periodFactor(Period period) { + return sum(slice(REVENUE_PER_MONTH, period)) / sum(slice(REVENUE_PER_MONTH, Period.HALF_YEAR)); + } + + /** A selected region's share of total revenue, or 1 for all regions. */ + private static double regionShare(DashboardFilter filter) { + if (filter.allRegions()) { + return 1; + } + int index = REGIONS.indexOf(filter.regionKey()); + if (index < 0) { + throw new IllegalArgumentException("unknown region: " + filter.regionKey()); + } + return REVENUE_PER_REGION.get(index) / sum(REVENUE_PER_REGION); + } + + private static List scale(List values, double factor, int decimals) { + if (factor == 1) { + return List.copyOf(values); + } + return values.stream().map(value -> round(value * factor, decimals)).toList(); + } + + private static double round(double value, int decimals) { + double unit = Math.pow(10, decimals); + return Math.round(value * unit) / unit; + } + + private static double sum(List values) { + return values.stream().mapToDouble(Double::doubleValue).sum(); } } diff --git a/src/main/java/com/example/data/KpiData.java b/src/main/java/com/example/data/KpiData.java index 1c97ce4..20c249c 100644 --- a/src/main/java/com/example/data/KpiData.java +++ b/src/main/java/com/example/data/KpiData.java @@ -3,22 +3,26 @@ 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. + * The numbers behind one KPI tile: the figure itself, 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."). + * layout to match it again), while {@code labelKey} is a translation key. The + * displayed value is {@code labelKey + ".value"} formatted with + * {@code value}: the bundle carries the unit and the locale's number pattern + * ("1,24 Mio. €" against "€1.24M"), the service carries only the number — which + * it has to, because the number depends on the {@link DashboardFilter}. */ -public record KpiData(String id, String labelKey, double deltaPercent, List trend) { +public record KpiData(String id, String labelKey, double value, double deltaPercent, + List trend) { public KpiData { trend = List.copyOf(trend); } - /** The bundle key of the pre-formatted display value. */ + /** The bundle key of the display value; a {@code MessageFormat} pattern + * taking {@link #value()} as its single argument. */ public String valueKey() { return labelKey + ".value"; } diff --git a/src/main/java/com/example/views/DashboardFilterBar.java b/src/main/java/com/example/views/DashboardFilterBar.java new file mode 100644 index 0000000..a42d23f --- /dev/null +++ b/src/main/java/com/example/views/DashboardFilterBar.java @@ -0,0 +1,63 @@ +package com.example.views; + +import com.example.data.DashboardFilter; +import com.example.data.DashboardFilter.Period; +import com.example.widgets.DashboardContext; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.select.Select; + +import java.util.List; + +/** + * The dashboard's global filter: reporting period and sales region. Picking a + * value pushes a new {@link DashboardFilter} into the {@link DashboardContext}, + * which is what re-feeds the widgets — the bar itself knows no widget and holds + * no data. + *

+ * Both selects are always populated, so there is no empty state to handle: the + * "all regions" entry is a region key like any other (see + * {@link DashboardFilter#ALL_REGIONS}). + */ +public class DashboardFilterBar extends HorizontalLayout { + + private static final List REGION_KEYS = List.of(DashboardFilter.ALL_REGIONS, + "region.north", "region.south", "region.east", "region.west"); + + private final Select period = new Select<>(); + private final Select region = new Select<>(); + + public DashboardFilterBar(DashboardContext context) { + setPadding(false); + setAlignItems(Alignment.END); + + period.setLabel(getTranslation("filter.period")); + period.setItems(Period.values()); + period.setItemLabelGenerator(value -> getTranslation(value.labelKey())); + period.setValue(context.getFilter().period()); + period.addValueChangeListener(e -> { + if (e.getValue() != null) { + context.setFilter(context.getFilter().withPeriod(e.getValue())); + } + }); + + region.setLabel(getTranslation("filter.region")); + region.setItems(REGION_KEYS); + region.setItemLabelGenerator(this::getTranslation); + region.setValue(context.getFilter().regionKey()); + region.addValueChangeListener(e -> { + if (e.getValue() != null) { + context.setFilter(context.getFilter().withRegion(e.getValue())); + } + }); + + add(period, region); + } + + public Select getPeriodSelect() { + return period; + } + + public Select getRegionSelect() { + return region; + } +} diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index c2fa66a..2a9935b 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -6,7 +6,9 @@ import com.example.components.GridStackItem; import com.example.components.GridStackLayout; import com.example.components.KpiTile; import com.example.data.ChartDataService; +import com.example.data.DashboardFilter; import com.example.data.KpiData; +import com.example.widgets.DashboardContext; import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetRegistry; import com.vaadin.flow.component.button.Button; @@ -19,7 +21,9 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.Route; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose @@ -31,6 +35,11 @@ import java.util.List; * All widget numbers come from {@link ChartDataService} and every chart widget * is built by the registry; this view only decides where a widget sits and * resolves the translation keys it is handed against the bundle. + *

+ * A {@link DashboardFilterBar} above the grid drives every widget through one + * {@link DashboardContext}: the charts subscribe to it themselves (in the + * registry), the KPI tiles are re-fed here, since this view is what built them. + * The filter is view state and is not persisted — see {@link DashboardContext}. */ @Route("") public class DashboardView extends VerticalLayout implements HasDynamicTitle { @@ -40,12 +49,18 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { /** 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 WidgetRegistry widgets; + private final DashboardContext context = new DashboardContext(); private final GridStackLayout grid = new GridStackLayout(); private final Span status = new Span(); + /** The KPI tiles by KPI id, so a filter change re-feeds each tile with the + * data of the same KPI rather than by position. */ + private final Map kpiTiles = new LinkedHashMap<>(); private int extraWidgetCount; public DashboardView(ChartDataService dataService, WidgetRegistry widgets) { + this.dataService = dataService; this.widgets = widgets; addClassName("dialect-content"); @@ -58,12 +73,14 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { // 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(); + List kpis = dataService.kpis(context.getFilter()); 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))); + KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi); + kpiTiles.put(kpi.id(), tile); + grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile)); } + context.addFilterChangeListener(this::updateKpiTiles); grid.add( defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3), @@ -76,7 +93,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { status.setText(getTranslation("gridstack.statusInitial")); status.addClassName("dialect-muted"); - add(toolbar(), grid); + add(toolbar(), new DashboardFilterBar(context), grid); } private HorizontalLayout toolbar() { @@ -138,18 +155,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { private GridStackItem widget(String id, WidgetDefinition definition, int x, int y, int w, int h) { - return new GridStackItem(id, x, y, w, h, - new Card(getTranslation(definition.titleKey()), definition.factory().get())); + return new GridStackItem(id, x, y, w, h, new Card(getTranslation(definition.titleKey()), + definition.factory().apply(context))); } - /** The displayed value comes from the bundle alongside the label, since it - * 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())) + /** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry + * in the map — the same KPI can be added back — but is detached, so + * feeding it would queue a client call for a chart that is not there. */ + private void updateKpiTiles(DashboardFilter filter) { + for (KpiData kpi : dataService.kpis(filter)) { + KpiTile tile = kpiTiles.get(kpi.id()); + if (tile != null && tile.isAttached()) { + feed(tile, kpi); + } + } + } + + /** The value's unit and number pattern come from the bundle, since they are + * locale-specific (decimal separator, currency, "Mio."); the service + * supplies only the number, which depends on the filter. */ + private KpiTile feed(KpiTile tile, KpiData kpi) { + return tile.setValue(getTranslation(kpi.valueKey(), kpi.value())) .setDelta(kpi.deltaPercent()) - .setSparkline(label, kpi.trend()); + .setSparkline(tile.getLabel(), kpi.trend()); } @Override diff --git a/src/main/java/com/example/widgets/DashboardContext.java b/src/main/java/com/example/widgets/DashboardContext.java new file mode 100644 index 0000000..bee808a --- /dev/null +++ b/src/main/java/com/example/widgets/DashboardContext.java @@ -0,0 +1,63 @@ +package com.example.widgets; + +import com.example.data.ChartDataService; +import com.example.data.DashboardFilter; +import com.vaadin.flow.shared.Registration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * The dashboard's current {@link DashboardFilter} plus the widgets listening to + * it — the small event bus between the filter bar (which sets) and the widgets + * (which subscribe and re-request their data from + * {@link ChartDataService}). Neither side knows the other. + *

+ * One context per dashboard instance, deliberately not a Spring bean: it is + * view state, so nothing leaks between users, tabs or visits. For the same + * reason the filter is not persisted — a reload starts from + * {@link DashboardFilter#defaults()}, unlike the grid layout, which + * {@code GridStackLayout} keeps in {@code localStorage}. + */ +public class DashboardContext { + + private final List> listeners = new ArrayList<>(); + private DashboardFilter filter = DashboardFilter.defaults(); + + public DashboardFilter getFilter() { + return filter; + } + + /** + * Applies a filter and hands it to every listener. A filter equal to the + * current one changes nothing and notifies nobody, so re-picking the same + * value in the filter bar does not make the widgets re-fetch. + */ + public void setFilter(DashboardFilter filter) { + Objects.requireNonNull(filter, "filter"); + if (this.filter.equals(filter)) { + return; + } + this.filter = filter; + // Over a copy: a listener may unsubscribe while being notified (a + // widget closing in reaction to the update). + List.copyOf(listeners).forEach(listener -> listener.accept(filter)); + } + + /** + * Subscribes to later filter changes. The listener is not called with the + * current filter — a widget is built from it already — so a subscriber + * feeds itself once and then only reacts. + */ + public Registration addFilterChangeListener(Consumer listener) { + listeners.add(listener); + return () -> listeners.remove(listener); + } + + /** How many widgets are currently listening; for tests and diagnostics. */ + public int getListenerCount() { + return listeners.size(); + } +} diff --git a/src/main/java/com/example/widgets/WidgetDefinition.java b/src/main/java/com/example/widgets/WidgetDefinition.java index d3dcbc6..44c07ef 100644 --- a/src/main/java/com/example/widgets/WidgetDefinition.java +++ b/src/main/java/com/example/widgets/WidgetDefinition.java @@ -2,7 +2,7 @@ package com.example.widgets; import com.vaadin.flow.component.Component; -import java.util.function.Supplier; +import java.util.function.Function; /** * One kind of dashboard widget: what it is called, how large it starts out, and @@ -15,9 +15,12 @@ import java.util.function.Supplier; * @param titleKey translation key for the card title * @param width default width in grid columns * @param height default height in grid rows - * @param factory builds the card content; called once per added widget, since - * a Vaadin component cannot be attached in two places + * @param factory builds the card content from the dashboard's + * {@link DashboardContext}: it reads the current filter for the + * first render and subscribes for later ones. Called once per + * added widget, since a Vaadin component cannot be attached in + * two places */ public record WidgetDefinition(String type, String titleKey, int width, int height, - Supplier factory) { + Function factory) { } diff --git a/src/main/java/com/example/widgets/WidgetRegistry.java b/src/main/java/com/example/widgets/WidgetRegistry.java index cf284dc..ce95051 100644 --- a/src/main/java/com/example/widgets/WidgetRegistry.java +++ b/src/main/java/com/example/widgets/WidgetRegistry.java @@ -7,13 +7,16 @@ import com.example.components.LineChart; import com.example.components.PieChart; import com.example.data.ChartDataService; import com.example.data.ChartSeries; +import com.example.data.DashboardFilter; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.shared.Registration; import org.springframework.stereotype.Service; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; /** * The widget types a dashboard can show. Holding them here rather than inline in @@ -21,9 +24,13 @@ import java.util.Map; * closed widget be brought back: the view only decides where a widget * sits, the registry decides what one is. *

- * Numbers come from {@link ChartDataService}; the factories resolve the data's - * translation keys against the bundle through the chart component itself, so - * they follow the current UI locale without the registry being a component. + * Numbers come from {@link ChartDataService}, queried with the filter the + * widget's {@link DashboardContext} currently holds; the factories resolve the + * data's translation keys against the bundle through the chart component + * itself, so they follow the current UI locale without the registry being a + * component. Every widget also subscribes to the context, so a filter change + * re-feeds it — in place, through {@code updateData}, rather than by rebuilding + * the chart. */ @Service public class WidgetRegistry { @@ -40,9 +47,9 @@ public class WidgetRegistry { this.dataService = dataService; register(new WidgetDefinition(REVENUE_TREND, "card.revenueTrend", 6, 3, - () -> axisChart(new LineChart()))); + context -> axisChart(new LineChart(), context))); register(new WidgetDefinition(REVENUE_MONTH, "card.revenueByMonth", 6, 3, - () -> axisChart(new BarChart()))); + context -> axisChart(new BarChart(), context))); register(new WidgetDefinition(REVENUE_REGION, "card.revenueByRegion", 5, 3, this::pieChart)); } @@ -66,22 +73,40 @@ public class WidgetRegistry { return definition; } - private Component axisChart(AxisChart chart) { - ChartSeries series = dataService.revenueByMonth(); - chart.setData(chart.getTranslation(series.nameKey()), series.values(), - translate(chart, series.categoryKeys())); + private Component axisChart(AxisChart chart, DashboardContext context) { chart.addPointClickListener(e -> Notification.show(chart.getTranslation( "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); - return sizeFull(chart); + return bind(chart, context, filter -> { + ChartSeries series = dataService.revenueByMonth(filter); + chart.updateData(chart.getTranslation(series.nameKey()), series.values(), + translate(chart, series.categoryKeys())); + }); } - private Component pieChart() { - ChartSeries series = dataService.revenueByRegion(); + private Component pieChart(DashboardContext context) { PieChart chart = new PieChart(); - // A pie has no series name — its categories are the slice labels. - chart.setData(series.values(), translate(chart, series.categoryKeys())); chart.addPointClickListener(e -> Notification.show( chart.getTranslation("chart.sliceClick", e.getDataPointIndex()))); + return bind(chart, context, filter -> { + ChartSeries series = dataService.revenueByRegion(filter); + // A pie has no series name — its categories are the slice labels. + chart.updateData(series.values(), translate(chart, series.categoryKeys())); + }); + } + + /** + * Feeds the chart from the current filter and keeps it fed: the + * subscription is dropped when the widget is detached (closed, or the + * dashboard left), so a chart that is gone is not updated any more. + *

+ * The first feed goes through the same {@code updateData} path as later + * ones, which falls back to a full render while nothing has been drawn yet. + */ + private Component bind(ApexChart chart, DashboardContext context, + Consumer feed) { + feed.accept(context.getFilter()); + Registration registration = context.addFilterChangeListener(feed); + chart.addDetachListener(e -> registration.remove()); return sizeFull(chart); } diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 5aca9d5..29e0757 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -31,14 +31,24 @@ gridstack.pickerTitle=Widget auswählen gridstack.dragHandle=Verschieben gridstack.close=Schließen +filter.period=Zeitraum +filter.period.month=Monat +filter.period.quarter=Quartal +filter.period.halfYear=Halbjahr +filter.period.year=Jahr +filter.region=Region +filter.allRegions=Alle Regionen + +# Die KPI-Werte sind MessageFormat-Muster: die Zahl kommt aus dem Datenservice +# (sie hängt vom Filter ab), Einheit und Zahlenformat aus dem Bundle. kpi.revenueTotal=Gesamtumsatz -kpi.revenueTotal.value=1,24 Mio. € +kpi.revenueTotal.value={0,number,#,##0.00} Mio. € kpi.openOrders=Offene Vorgänge -kpi.openOrders.value=38 +kpi.openOrders.value={0,number,#,##0} kpi.newCustomers=Neue Kunden -kpi.newCustomers.value=112 +kpi.newCustomers.value={0,number,#,##0} kpi.averageOrderValue=Ø Bestellwert -kpi.averageOrderValue.value=486 € +kpi.averageOrderValue.value={0,number,#,##0} € chart.revenueSeries=Umsatz 2026 chart.pointClick=Serie {0}, Punkt {1} @@ -50,6 +60,17 @@ month.mar=Mär month.apr=Apr month.may=Mai month.jun=Jun +month.jul=Jul +month.aug=Aug +month.sep=Sep +month.oct=Okt +month.nov=Nov +month.dec=Dez + +week.1=Woche 1 +week.2=Woche 2 +week.3=Woche 3 +week.4=Woche 4 region.north=Nord region.south=Süd diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index 3002881..f0ee188 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -31,14 +31,24 @@ gridstack.pickerTitle=Choose a widget gridstack.dragHandle=Move gridstack.close=Close +filter.period=Period +filter.period.month=Month +filter.period.quarter=Quarter +filter.period.halfYear=Half year +filter.period.year=Year +filter.region=Region +filter.allRegions=All regions + +# The KPI values are MessageFormat patterns: the number comes from the data +# service (it depends on the filter), unit and number format from the bundle. kpi.revenueTotal=Total revenue -kpi.revenueTotal.value=€1.24M +kpi.revenueTotal.value=€{0,number,#,##0.00}M kpi.openOrders=Open orders -kpi.openOrders.value=38 +kpi.openOrders.value={0,number,#,##0} kpi.newCustomers=New customers -kpi.newCustomers.value=112 +kpi.newCustomers.value={0,number,#,##0} kpi.averageOrderValue=Avg. order value -kpi.averageOrderValue.value=€486 +kpi.averageOrderValue.value=€{0,number,#,##0} chart.revenueSeries=Revenue 2026 chart.pointClick=Series {0}, point {1} @@ -50,6 +60,17 @@ month.mar=Mar month.apr=Apr month.may=May month.jun=Jun +month.jul=Jul +month.aug=Aug +month.sep=Sep +month.oct=Oct +month.nov=Nov +month.dec=Dec + +week.1=Week 1 +week.2=Week 2 +week.3=Week 3 +week.4=Week 4 region.north=North region.south=South diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index 3159d38..d6ca742 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -31,14 +31,24 @@ gridstack.pickerTitle=Elegir un widget gridstack.dragHandle=Mover gridstack.close=Cerrar +filter.period=Periodo +filter.period.month=Mes +filter.period.quarter=Trimestre +filter.period.halfYear=Semestre +filter.period.year=Año +filter.region=Región +filter.allRegions=Todas las regiones + +# Los valores KPI son patrones MessageFormat: el número viene del servicio de +# datos (depende del filtro), la unidad y el formato numérico del bundle. kpi.revenueTotal=Ingresos totales -kpi.revenueTotal.value=1,24 M€ +kpi.revenueTotal.value={0,number,#,##0.00} M€ kpi.openOrders=Pedidos abiertos -kpi.openOrders.value=38 +kpi.openOrders.value={0,number,#,##0} kpi.newCustomers=Nuevos clientes -kpi.newCustomers.value=112 +kpi.newCustomers.value={0,number,#,##0} kpi.averageOrderValue=Valor medio del pedido -kpi.averageOrderValue.value=486 € +kpi.averageOrderValue.value={0,number,#,##0} € chart.revenueSeries=Ingresos 2026 chart.pointClick=Serie {0}, punto {1} @@ -50,6 +60,17 @@ month.mar=Mar month.apr=Abr month.may=May month.jun=Jun +month.jul=Jul +month.aug=Ago +month.sep=Sep +month.oct=Oct +month.nov=Nov +month.dec=Dic + +week.1=Semana 1 +week.2=Semana 2 +week.3=Semana 3 +week.4=Semana 4 region.north=Norte region.south=Sur diff --git a/src/test/java/com/example/components/ApexChartUpdateTest.java b/src/test/java/com/example/components/ApexChartUpdateTest.java index e922f86..6d148c0 100644 --- a/src/test/java/com/example/components/ApexChartUpdateTest.java +++ b/src/test/java/com/example/components/ApexChartUpdateTest.java @@ -1,6 +1,8 @@ package com.example.components; import com.example.Application; +import com.example.data.DashboardFilter.Period; +import com.example.views.DashboardFilterBar; import com.example.views.DashboardView; import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.ViewPackages; @@ -73,6 +75,29 @@ class ApexChartUpdateTest extends SpringBrowserlessTest { assertTrue(json.contains("\"labels\""), json); } + /** + * The dashboard's own path into this: a filter change must reach every + * widget as a data patch, so the whole dashboard updates without a single + * chart being rebuilt. + */ + @Test + void filterChange_patchesEveryChartInPlace() { + navigate(DashboardView.class); + drainInvocations(); + + $view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.QUARTER); + + // Everything the filter change queued — including the select's own + // client-side bookkeeping, which is not a chart call. + List calls = dump().stream() + .map(pending -> pending.getInvocation().getExpression()).toList(); + // Three charts plus one sparkline per KPI tile. + assertEquals(7, calls.stream().filter(call -> call.contains("updateData")).count(), + "expected every widget to be re-fed: " + calls); + assertTrue(calls.stream().noneMatch(call -> call.contains("renderChart")), + "a filter change must not rebuild a chart: " + calls); + } + /** The JSON payload of a {@code callJsFunction} invocation — parameter 0 is * the element the function is called on. */ private String json(JavaScriptInvocation call) { diff --git a/src/test/java/com/example/data/InMemoryChartDataServiceTest.java b/src/test/java/com/example/data/InMemoryChartDataServiceTest.java index 5ec95a3..5e62655 100644 --- a/src/test/java/com/example/data/InMemoryChartDataServiceTest.java +++ b/src/test/java/com/example/data/InMemoryChartDataServiceTest.java @@ -1,5 +1,6 @@ package com.example.data; +import com.example.data.DashboardFilter.Period; import org.junit.jupiter.api.Test; import java.util.List; @@ -54,6 +55,72 @@ class InMemoryChartDataServiceTest { assertEquals("kpi.revenueTotal", revenue.labelKey()); assertEquals("kpi.revenueTotal.value", revenue.valueKey()); assertEquals(12.4, revenue.deltaPercent()); + assertEquals(1.24, revenue.value()); + } + + @Test + void period_decidesTheGranularityAndTheRange() { + assertEquals(4, monthly(Period.MONTH).values().size(), "a month is read as weeks"); + assertEquals(List.of("week.1", "week.2", "week.3", "week.4"), + monthly(Period.MONTH).categoryKeys()); + + assertEquals(List.of("month.apr", "month.may", "month.jun"), + monthly(Period.QUARTER).categoryKeys()); + assertEquals(12, monthly(Period.YEAR).values().size()); + assertEquals("month.dec", monthly(Period.YEAR).categoryKeys().getLast()); + } + + /** The four weeks of a month add up to the month again. */ + @Test + void month_splitsTheCurrentMonthOverItsWeeks() { + double weeks = monthly(Period.MONTH).values().stream() + .mapToDouble(Double::doubleValue).sum(); + + assertEquals(monthly(Period.HALF_YEAR).values().getLast(), weeks, 0.001); + } + + @Test + void aLongerPeriod_growsTheCumulativeFigures() { + double half = revenueKpi(new DashboardFilter(Period.HALF_YEAR, DashboardFilter.ALL_REGIONS)); + double year = revenueKpi(new DashboardFilter(Period.YEAR, DashboardFilter.ALL_REGIONS)); + + assertTrue(year > half, "a year covers more revenue than half of one: " + year); + // The pie is the same figure sliced by region, so it grows along. + assertTrue(total(service.revenueByRegion(new DashboardFilter(Period.YEAR, + DashboardFilter.ALL_REGIONS))) > total(service.revenueByRegion()), + "the region breakdown follows the period"); + } + + @Test + void aRegion_isAShareOfTheTotal() { + DashboardFilter north = DashboardFilter.defaults().withRegion("region.north"); + + ChartSeries series = service.revenueByMonth(north); + assertEquals(6, series.values().size(), "the region does not change the range"); + assertTrue(total(series) < total(service.revenueByMonth()), + "one region is less than all of them: " + series.values()); + + // The pie *is* the region breakdown, so it ignores a region selection. + assertEquals(service.revenueByRegion(), service.revenueByRegion(north)); + } + + @Test + void unknownRegion_isRejected() { + DashboardFilter unknown = DashboardFilter.defaults().withRegion("region.moon"); + + assertThrows(IllegalArgumentException.class, () -> service.revenueByMonth(unknown)); + } + + private ChartSeries monthly(Period period) { + return service.revenueByMonth(new DashboardFilter(period, DashboardFilter.ALL_REGIONS)); + } + + private double revenueKpi(DashboardFilter filter) { + return service.kpis(filter).getFirst().value(); + } + + private double total(ChartSeries series) { + return series.values().stream().mapToDouble(Double::doubleValue).sum(); } @Test diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java index 5b73878..c06cabd 100644 --- a/src/test/java/com/example/views/DashboardViewTest.java +++ b/src/test/java/com/example/views/DashboardViewTest.java @@ -7,6 +7,10 @@ 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.DashboardFilter; +import com.example.data.DashboardFilter.Period; +import com.example.data.KpiData; import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetRegistry; import com.vaadin.browserless.SpringBrowserlessTest; @@ -25,6 +29,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -38,6 +43,9 @@ class DashboardViewTest extends SpringBrowserlessTest { @Autowired private WidgetRegistry registry; + @Autowired + private ChartDataService dataService; + @Test void view_rendersGridWithDefaultWidgets() { navigate(DashboardView.class); @@ -59,7 +67,7 @@ class DashboardViewTest extends SpringBrowserlessTest { KpiTile revenue = tiles.getFirst(); assertEquals(translate("kpi.revenueTotal"), revenue.getLabel()); - assertEquals(translate("kpi.revenueTotal.value"), revenue.getValue()); + assertEquals(translate("kpi.revenueTotal.value", 1.24), revenue.getValue()); // +12.4 — sign is explicit, decimal separator is the locale's. assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"), "unexpected delta text: " + revenue.getDeltaText()); @@ -91,6 +99,57 @@ class DashboardViewTest extends SpringBrowserlessTest { assertNotNull($view(PieChart.class).first()); } + @Test + void filterBar_startsOnTheDefaultFilter() { + navigate(DashboardView.class); + + DashboardFilterBar bar = $view(DashboardFilterBar.class).first(); + assertEquals(DashboardFilter.defaults().period(), bar.getPeriodSelect().getValue()); + assertEquals(DashboardFilter.ALL_REGIONS, bar.getRegionSelect().getValue()); + } + + @Test + void changingThePeriod_refeedsTheKpiTiles() { + navigate(DashboardView.class); + KpiTile revenue = $view(KpiTile.class).all().getFirst(); + String before = revenue.getValue(); + + $view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.YEAR); + + assertNotEquals(before, revenue.getValue(), "a longer period means a larger total"); + assertEquals(expectedValue("kpi-revenue", + DashboardFilter.defaults().withPeriod(Period.YEAR)), + revenue.getValue()); + } + + @Test + void changingTheRegion_refeedsTheKpiTiles() { + navigate(DashboardView.class); + KpiTile revenue = $view(KpiTile.class).all().getFirst(); + String before = revenue.getValue(); + + $view(DashboardFilterBar.class).first().getRegionSelect().setValue("region.north"); + + assertNotEquals(before, revenue.getValue(), "one region is a share of the total"); + assertEquals(expectedValue("kpi-revenue", + DashboardFilter.defaults().withRegion("region.north")), + revenue.getValue()); + } + + /** A closed tile must not be re-fed: it is detached, so an update would + * queue a client call for a chart that is no longer there. */ + @Test + void filterChange_skipsClosedKpiTiles() { + navigate(DashboardView.class); + KpiTile revenue = $view(KpiTile.class).all().getFirst(); + String before = revenue.getValue(); + + clickCloseButton(itemById("kpi-revenue")); + $view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.YEAR); + + assertEquals(before, revenue.getValue()); + } + @Test void addAndCloseWidget_changesItemCount() { navigate(DashboardView.class); @@ -228,6 +287,15 @@ class DashboardViewTest extends SpringBrowserlessTest { $(Button.class).from(picker).withText(translate(titleKey)).first().click(); } + /** What the tile of the given KPI must read under that filter, formatted the + * way the view formats it — the number comes from the service, the pattern + * from the bundle. */ + private String expectedValue(String kpiId, DashboardFilter filter) { + KpiData kpi = dataService.kpis(filter).stream() + .filter(data -> kpiId.equals(data.id())).findFirst().orElseThrow(); + return translate(kpi.valueKey(), kpi.value()); + } + private GridStackItem itemById(String id) { return $view(GridStackItem.class) .withCondition(item -> id.equals(item.getItemId())).single(); @@ -237,8 +305,8 @@ class DashboardViewTest extends SpringBrowserlessTest { return $view(Button.class).withText(translate(translationKey)).first(); } - private String translate(String translationKey) { + private String translate(String translationKey, Object... params) { return getCurrentView().getElement().getComponent() - .orElseThrow().getTranslation(translationKey); + .orElseThrow().getTranslation(translationKey, params); } } diff --git a/src/test/java/com/example/widgets/DashboardContextTest.java b/src/test/java/com/example/widgets/DashboardContextTest.java new file mode 100644 index 0000000..d688084 --- /dev/null +++ b/src/test/java/com/example/widgets/DashboardContextTest.java @@ -0,0 +1,73 @@ +package com.example.widgets; + +import com.example.data.DashboardFilter; +import com.example.data.DashboardFilter.Period; +import com.vaadin.flow.shared.Registration; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The filter bus is plain Java: no Spring context, no Vaadin UI. */ +class DashboardContextTest { + + private final DashboardContext context = new DashboardContext(); + private final List seen = new ArrayList<>(); + + @Test + void startsOnTheDefaultFilter() { + assertEquals(DashboardFilter.defaults(), context.getFilter()); + } + + @Test + void setFilter_notifiesEverySubscriber() { + context.addFilterChangeListener(seen::add); + context.addFilterChangeListener(seen::add); + + context.setFilter(context.getFilter().withPeriod(Period.YEAR)); + + assertEquals(2, seen.size()); + assertTrue(seen.stream().allMatch(filter -> filter.period() == Period.YEAR)); + assertEquals(Period.YEAR, context.getFilter().period()); + } + + @Test + void setFilter_withAnUnchangedFilter_notifiesNobody() { + context.addFilterChangeListener(seen::add); + + context.setFilter(DashboardFilter.defaults()); + + assertTrue(seen.isEmpty(), "an unchanged filter must not make widgets re-fetch"); + } + + @Test + void removedListener_isNotNotifiedAgain() { + Registration registration = context.addFilterChangeListener(seen::add); + + context.setFilter(context.getFilter().withRegion("region.north")); + registration.remove(); + context.setFilter(context.getFilter().withPeriod(Period.MONTH)); + + assertEquals(1, seen.size(), "a closed widget must stop being fed"); + assertEquals(0, context.getListenerCount()); + } + + /** A widget may close itself while reacting, which unsubscribes it from + * inside the notification loop. */ + @Test + void listener_mayUnsubscribeWhileBeingNotified() { + Registration[] registration = new Registration[1]; + registration[0] = context.addFilterChangeListener(filter -> { + seen.add(filter); + registration[0].remove(); + }); + + context.setFilter(context.getFilter().withPeriod(Period.QUARTER)); + context.setFilter(context.getFilter().withPeriod(Period.YEAR)); + + assertEquals(1, seen.size()); + } +} diff --git a/src/test/java/com/example/widgets/WidgetRegistryTest.java b/src/test/java/com/example/widgets/WidgetRegistryTest.java index 2f8e51f..5fc3da9 100644 --- a/src/test/java/com/example/widgets/WidgetRegistryTest.java +++ b/src/test/java/com/example/widgets/WidgetRegistryTest.java @@ -38,7 +38,7 @@ class WidgetRegistryTest { void register_replacesTheDefinitionOfTheSameType() { int before = registry.definitions().size(); WidgetDefinition replacement = new WidgetDefinition(WidgetRegistry.REVENUE_TREND, - "card.revenueTrend", 2, 2, () -> null); + "card.revenueTrend", 2, 2, context -> null); registry.register(replacement);