feat: global dashboard filter bar (date range, region) (#24) #36

Merged
pitfriedrich merged 1 commits from ai/issue-24-dashboard-filter-bar into main 2026-07-28 18:57:16 +00:00
19 changed files with 762 additions and 84 deletions
@@ -100,7 +100,8 @@ public class KpiTile extends Div {
return deltaText; 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<Double> values) { public KpiTile setSparkline(String seriesName, List<Double> values) {
if (sparkline == null) { if (sparkline == null) {
sparkline = new SparklineChart(); sparkline = new SparklineChart();
@@ -111,7 +112,7 @@ public class KpiTile extends Div {
wrapper.addClassName("dialect-kpi__sparkline"); wrapper.addClassName("dialect-kpi__sparkline");
add(wrapper); add(wrapper);
} }
sparkline.setData(seriesName, values); sparkline.updateData(seriesName, values);
return this; return this;
} }
@@ -27,4 +27,18 @@ public class SparklineChart extends ApexChart {
sendOptions(options); 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<Double> values) {
if (!hasSentOptions()) {
setData(seriesName, values);
return;
}
sendDataPatch(Map.of("series", List.of(Map.of("name", seriesName, "data", values))));
}
} }
@@ -8,18 +8,36 @@ import java.util.List;
* be re-fed later (live refresh, a global filter, a per-widget refresh action) * 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. * and so the data logic is testable without a Vaadin component tree.
* <p> * <p>
* 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()}.
* <p>
* Deliberately thin: the current implementation * Deliberately thin: the current implementation
* ({@link InMemoryChartDataService}) is in-memory dummy data. A real backend * ({@link InMemoryChartDataService}) is in-memory dummy data. A real backend
* would replace the implementation, not this interface. * would replace the implementation, not this interface.
*/ */
public interface ChartDataService { public interface ChartDataService {
/** Revenue per month, for the line and bar charts. */ /** Revenue per month (per week for {@code Period.MONTH}), for the line and
ChartSeries revenueByMonth(); * bar charts. Honours both the period and a region selection. */
ChartSeries revenueByMonth(DashboardFilter filter);
/** Revenue per sales region, for the pie chart. */ /** Revenue per sales region, for the pie chart. Honours the period; a
ChartSeries revenueByRegion(); * 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. */ /** The KPI tiles, in the order the dashboard lays them out. */
List<KpiData> kpis(); List<KpiData> kpis(DashboardFilter filter);
default ChartSeries revenueByMonth() {
return revenueByMonth(DashboardFilter.defaults());
}
default ChartSeries revenueByRegion() {
return revenueByRegion(DashboardFilter.defaults());
}
default List<KpiData> kpis() {
return kpis(DashboardFilter.defaults());
}
} }
@@ -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.
* <p>
* Every {@link ChartDataService} query takes one of these; widgets a given
* dimension does not apply to simply ignore it (the region pie chart <em>is</em>
* 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;
}
}
}
@@ -1,51 +1,142 @@
package com.example.data; package com.example.data;
import com.example.data.DashboardFilter.Period;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
/** /**
* Dummy implementation returning the hardcoded numbers the views used to carry * Dummy implementation over a hardcoded year of numbers. There is no query
* inline. Everything is a constant, so each call returns the same data — the * engine behind this: a {@link DashboardFilter} selects a slice of the base
* point of the service is the seam, not a persistence layer. * year and scales it, deterministically, so the same filter always yields the
* same data.
* <p>
* The scaling is relative to {@link DashboardFilter#defaults()}, so the
* unfiltered dashboard shows exactly the figures it always did.
*/ */
@Service @Service
public class InMemoryChartDataService implements ChartDataService { public class InMemoryChartDataService implements ChartDataService {
private static final List<String> MONTHS = List.of( private static final List<String> 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<String> 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<Double> 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<String> REGIONS = List.of( private static final List<String> REGIONS = List.of(
"region.north", "region.south", "region.east", "region.west"); "region.north", "region.south", "region.east", "region.west");
private static final ChartSeries REVENUE_BY_MONTH = new ChartSeries( /** Revenue per region over the default half year; also the weights a region
"chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), MONTHS); * selection scales the other widgets by. */
private static final List<Double> REVENUE_PER_REGION = List.of(30.0, 40.0, 35.0, 50.0);
private static final ChartSeries REVENUE_BY_REGION = new ChartSeries( private static final List<Double> REVENUE_PER_MONTH = List.of(
"chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0), REGIONS); 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<KpiData> KPIS = List.of( private static final List<Double> OPEN_ORDERS_TREND = List.of(
new KpiData("kpi-revenue", "kpi.revenueTotal", 12.4, 52.0, 47.0, 49.0, 44.0, 40.0, 38.0, 41.0, 39.0, 36.0, 34.0, 33.0, 30.0);
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0)),
new KpiData("kpi-orders", "kpi.openOrders", -3.1, private static final List<Double> NEW_CUSTOMERS_TREND = List.of(
List.of(52.0, 47.0, 49.0, 44.0, 40.0, 38.0)), 74.0, 81.0, 79.0, 95.0, 104.0, 112.0, 108.0, 118.0, 121.0, 130.0, 127.0, 140.0);
new KpiData("kpi-customers", "kpi.newCustomers", 8.0,
List.of(74.0, 81.0, 79.0, 95.0, 104.0, 112.0)), private static final List<Double> ORDER_VALUE_TREND = List.of(
new KpiData("kpi-order-value", "kpi.averageOrderValue", 0.0, 480.0, 492.0, 478.0, 489.0, 483.0, 486.0, 491.0, 488.0, 494.0, 490.0, 497.0, 499.0);
List.of(480.0, 492.0, 478.0, 489.0, 483.0, 486.0)));
@Override @Override
public ChartSeries revenueByMonth() { public ChartSeries revenueByMonth(DashboardFilter filter) {
return REVENUE_BY_MONTH; return new ChartSeries("chart.revenueSeries",
scale(slice(REVENUE_PER_MONTH, filter.period()), regionShare(filter), 1),
categoryKeys(filter.period()));
} }
@Override @Override
public ChartSeries revenueByRegion() { public ChartSeries revenueByRegion(DashboardFilter filter) {
return REVENUE_BY_REGION; // 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 @Override
public List<KpiData> kpis() { public List<KpiData> kpis(DashboardFilter filter) {
return KPIS; 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<Double> slice(List<Double> 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<String> 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<Double> scale(List<Double> 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<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).sum();
} }
} }
+11 -7
View File
@@ -3,22 +3,26 @@ package com.example.data;
import java.util.List; import java.util.List;
/** /**
* The numbers behind one KPI tile: the change versus the previous period and * The numbers behind one KPI tile: the figure itself, the change versus the
* the trend the sparkline draws. * previous period and the trend the sparkline draws.
* <p> * <p>
* {@code id} identifies the KPI itself (the dashboard reuses it as the grid * {@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 * 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 * layout to match it again), while {@code labelKey} is a translation key. The
* displayed value is looked up as {@code labelKey + ".value"}, because it * displayed value is {@code labelKey + ".value"} <em>formatted with</em>
* carries locale-specific formatting (decimal separator, currency, "Mio."). * {@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<Double> trend) { public record KpiData(String id, String labelKey, double value, double deltaPercent,
List<Double> trend) {
public KpiData { public KpiData {
trend = List.copyOf(trend); 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() { public String valueKey() {
return labelKey + ".value"; return labelKey + ".value";
} }
@@ -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.
* <p>
* 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<String> REGION_KEYS = List.of(DashboardFilter.ALL_REGIONS,
"region.north", "region.south", "region.east", "region.west");
private final Select<Period> period = new Select<>();
private final Select<String> 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<Period> getPeriodSelect() {
return period;
}
public Select<String> getRegionSelect() {
return region;
}
}
@@ -6,7 +6,9 @@ import com.example.components.GridStackItem;
import com.example.components.GridStackLayout; import com.example.components.GridStackLayout;
import com.example.components.KpiTile; import com.example.components.KpiTile;
import com.example.data.ChartDataService; import com.example.data.ChartDataService;
import com.example.data.DashboardFilter;
import com.example.data.KpiData; import com.example.data.KpiData;
import com.example.widgets.DashboardContext;
import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry; import com.example.widgets.WidgetRegistry;
import com.vaadin.flow.component.button.Button; 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.HasDynamicTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose * 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 * 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 * is built by the registry; this view only decides where a widget sits and
* resolves the translation keys it is handed against the bundle. * resolves the translation keys it is handed against the bundle.
* <p>
* 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("") @Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle { 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. */ /** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3; private static final int KPI_WIDTH = 3;
private final ChartDataService dataService;
private final WidgetRegistry widgets; private final WidgetRegistry widgets;
private final DashboardContext context = new DashboardContext();
private final GridStackLayout grid = new GridStackLayout(); private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span(); 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<String, KpiTile> kpiTiles = new LinkedHashMap<>();
private int extraWidgetCount; private int extraWidgetCount;
public DashboardView(ChartDataService dataService, WidgetRegistry widgets) { public DashboardView(ChartDataService dataService, WidgetRegistry widgets) {
this.dataService = dataService;
this.widgets = widgets; this.widgets = widgets;
addClassName("dialect-content"); 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, // 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 // 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. // grid id is the KPI's own id, so it survives reordering.
List<KpiData> kpis = dataService.kpis(); List<KpiData> kpis = dataService.kpis(context.getFilter());
for (int i = 0; i < kpis.size(); i++) { for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i); KpiData kpi = kpis.get(i);
grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
kpiTile(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( grid.add(
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3), 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.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted"); status.addClassName("dialect-muted");
add(toolbar(), grid); add(toolbar(), new DashboardFilterBar(context), grid);
} }
private HorizontalLayout toolbar() { private HorizontalLayout toolbar() {
@@ -138,18 +155,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private GridStackItem widget(String id, WidgetDefinition definition, private GridStackItem widget(String id, WidgetDefinition definition,
int x, int y, int w, int h) { int x, int y, int w, int h) {
return new GridStackItem(id, x, y, w, h, return new GridStackItem(id, x, y, w, h, new Card(getTranslation(definition.titleKey()),
new Card(getTranslation(definition.titleKey()), definition.factory().get())); definition.factory().apply(context)));
} }
/** The displayed value comes from the bundle alongside the label, since it /** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry
* carries locale-specific formatting (decimal separator, currency, "Mio."); * in the map — the same KPI can be added back — but is detached, so
* the service supplies the key, not the formatted text. */ * feeding it would queue a client call for a chart that is not there. */
private KpiTile kpiTile(KpiData kpi) { private void updateKpiTiles(DashboardFilter filter) {
String label = getTranslation(kpi.labelKey()); for (KpiData kpi : dataService.kpis(filter)) {
return new KpiTile(label, getTranslation(kpi.valueKey())) 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()) .setDelta(kpi.deltaPercent())
.setSparkline(label, kpi.trend()); .setSparkline(tile.getLabel(), kpi.trend());
} }
@Override @Override
@@ -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.
* <p>
* 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 <em>not persisted</em> — a reload starts from
* {@link DashboardFilter#defaults()}, unlike the grid layout, which
* {@code GridStackLayout} keeps in {@code localStorage}.
*/
public class DashboardContext {
private final List<Consumer<DashboardFilter>> 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<DashboardFilter> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
/** How many widgets are currently listening; for tests and diagnostics. */
public int getListenerCount() {
return listeners.size();
}
}
@@ -2,7 +2,7 @@ package com.example.widgets;
import com.vaadin.flow.component.Component; 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 * 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 titleKey translation key for the card title
* @param width default width in grid columns * @param width default width in grid columns
* @param height default height in grid rows * @param height default height in grid rows
* @param factory builds the card content; called once per added widget, since * @param factory builds the card content from the dashboard's
* a Vaadin component cannot be attached in two places * {@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, public record WidgetDefinition(String type, String titleKey, int width, int height,
Supplier<Component> factory) { Function<DashboardContext, Component> factory) {
} }
@@ -7,13 +7,16 @@ import com.example.components.LineChart;
import com.example.components.PieChart; import com.example.components.PieChart;
import com.example.data.ChartDataService; import com.example.data.ChartDataService;
import com.example.data.ChartSeries; import com.example.data.ChartSeries;
import com.example.data.DashboardFilter;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.shared.Registration;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
/** /**
* The widget types a dashboard can show. Holding them here rather than inline in * 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 <em>where</em> a widget * closed widget be brought back: the view only decides <em>where</em> a widget
* sits, the registry decides what one <em>is</em>. * sits, the registry decides what one <em>is</em>.
* <p> * <p>
* Numbers come from {@link ChartDataService}; the factories resolve the data's * Numbers come from {@link ChartDataService}, queried with the filter the
* translation keys against the bundle through the chart component itself, so * widget's {@link DashboardContext} currently holds; the factories resolve the
* they follow the current UI locale without the registry being a component. * 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 @Service
public class WidgetRegistry { public class WidgetRegistry {
@@ -40,9 +47,9 @@ public class WidgetRegistry {
this.dataService = dataService; this.dataService = dataService;
register(new WidgetDefinition(REVENUE_TREND, "card.revenueTrend", 6, 3, 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, 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, register(new WidgetDefinition(REVENUE_REGION, "card.revenueByRegion", 5, 3,
this::pieChart)); this::pieChart));
} }
@@ -66,22 +73,40 @@ public class WidgetRegistry {
return definition; return definition;
} }
private Component axisChart(AxisChart chart) { private Component axisChart(AxisChart chart, DashboardContext context) {
ChartSeries series = dataService.revenueByMonth();
chart.setData(chart.getTranslation(series.nameKey()), series.values(),
translate(chart, series.categoryKeys()));
chart.addPointClickListener(e -> Notification.show(chart.getTranslation( chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); "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() { private Component pieChart(DashboardContext context) {
ChartSeries series = dataService.revenueByRegion();
PieChart chart = new PieChart(); 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.addPointClickListener(e -> Notification.show(
chart.getTranslation("chart.sliceClick", e.getDataPointIndex()))); 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.
* <p>
* 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<DashboardFilter> feed) {
feed.accept(context.getFilter());
Registration registration = context.addFilterChangeListener(feed);
chart.addDetachListener(e -> registration.remove());
return sizeFull(chart); return sizeFull(chart);
} }
@@ -31,14 +31,24 @@ gridstack.pickerTitle=Widget auswählen
gridstack.dragHandle=Verschieben gridstack.dragHandle=Verschieben
gridstack.close=Schließen 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=Gesamtumsatz
kpi.revenueTotal.value=1,24 Mio. € kpi.revenueTotal.value={0,number,#,##0.00} Mio. €
kpi.openOrders=Offene Vorgänge kpi.openOrders=Offene Vorgänge
kpi.openOrders.value=38 kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=Neue Kunden kpi.newCustomers=Neue Kunden
kpi.newCustomers.value=112 kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Ø Bestellwert kpi.averageOrderValue=Ø Bestellwert
kpi.averageOrderValue.value=486 kpi.averageOrderValue.value={0,number,#,##0}
chart.revenueSeries=Umsatz 2026 chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1} chart.pointClick=Serie {0}, Punkt {1}
@@ -50,6 +60,17 @@ month.mar=Mär
month.apr=Apr month.apr=Apr
month.may=Mai month.may=Mai
month.jun=Jun 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.north=Nord
region.south=Süd region.south=Süd
@@ -31,14 +31,24 @@ gridstack.pickerTitle=Choose a widget
gridstack.dragHandle=Move gridstack.dragHandle=Move
gridstack.close=Close 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=Total revenue
kpi.revenueTotal.value=1.24M kpi.revenueTotal.value={0,number,#,##0.00}M
kpi.openOrders=Open orders kpi.openOrders=Open orders
kpi.openOrders.value=38 kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=New customers kpi.newCustomers=New customers
kpi.newCustomers.value=112 kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Avg. order value kpi.averageOrderValue=Avg. order value
kpi.averageOrderValue.value=486 kpi.averageOrderValue.value={0,number,#,##0}
chart.revenueSeries=Revenue 2026 chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1} chart.pointClick=Series {0}, point {1}
@@ -50,6 +60,17 @@ month.mar=Mar
month.apr=Apr month.apr=Apr
month.may=May month.may=May
month.jun=Jun 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.north=North
region.south=South region.south=South
@@ -31,14 +31,24 @@ gridstack.pickerTitle=Elegir un widget
gridstack.dragHandle=Mover gridstack.dragHandle=Mover
gridstack.close=Cerrar 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=Ingresos totales
kpi.revenueTotal.value=1,24 M€ kpi.revenueTotal.value={0,number,#,##0.00} M€
kpi.openOrders=Pedidos abiertos kpi.openOrders=Pedidos abiertos
kpi.openOrders.value=38 kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=Nuevos clientes kpi.newCustomers=Nuevos clientes
kpi.newCustomers.value=112 kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Valor medio del pedido kpi.averageOrderValue=Valor medio del pedido
kpi.averageOrderValue.value=486 kpi.averageOrderValue.value={0,number,#,##0}
chart.revenueSeries=Ingresos 2026 chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1} chart.pointClick=Serie {0}, punto {1}
@@ -50,6 +60,17 @@ month.mar=Mar
month.apr=Abr month.apr=Abr
month.may=May month.may=May
month.jun=Jun 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.north=Norte
region.south=Sur region.south=Sur
@@ -1,6 +1,8 @@
package com.example.components; package com.example.components;
import com.example.Application; import com.example.Application;
import com.example.data.DashboardFilter.Period;
import com.example.views.DashboardFilterBar;
import com.example.views.DashboardView; import com.example.views.DashboardView;
import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages; import com.vaadin.browserless.ViewPackages;
@@ -73,6 +75,29 @@ class ApexChartUpdateTest extends SpringBrowserlessTest {
assertTrue(json.contains("\"labels\""), json); 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<String> 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 JSON payload of a {@code callJsFunction} invocation — parameter 0 is
* the element the function is called on. */ * the element the function is called on. */
private String json(JavaScriptInvocation call) { private String json(JavaScriptInvocation call) {
@@ -1,5 +1,6 @@
package com.example.data; package com.example.data;
import com.example.data.DashboardFilter.Period;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
@@ -54,6 +55,72 @@ class InMemoryChartDataServiceTest {
assertEquals("kpi.revenueTotal", revenue.labelKey()); assertEquals("kpi.revenueTotal", revenue.labelKey());
assertEquals("kpi.revenueTotal.value", revenue.valueKey()); assertEquals("kpi.revenueTotal.value", revenue.valueKey());
assertEquals(12.4, revenue.deltaPercent()); 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 @Test
@@ -7,6 +7,10 @@ import com.example.components.GridStackLayout;
import com.example.components.KpiTile; 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.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.WidgetDefinition;
import com.example.widgets.WidgetRegistry; import com.example.widgets.WidgetRegistry;
import com.vaadin.browserless.SpringBrowserlessTest; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -38,6 +43,9 @@ class DashboardViewTest extends SpringBrowserlessTest {
@Autowired @Autowired
private WidgetRegistry registry; private WidgetRegistry registry;
@Autowired
private ChartDataService dataService;
@Test @Test
void view_rendersGridWithDefaultWidgets() { void view_rendersGridWithDefaultWidgets() {
navigate(DashboardView.class); navigate(DashboardView.class);
@@ -59,7 +67,7 @@ class DashboardViewTest extends SpringBrowserlessTest {
KpiTile revenue = tiles.getFirst(); KpiTile revenue = tiles.getFirst();
assertEquals(translate("kpi.revenueTotal"), revenue.getLabel()); 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. // +12.4 — sign is explicit, decimal separator is the locale's.
assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"), assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"),
"unexpected delta text: " + revenue.getDeltaText()); "unexpected delta text: " + revenue.getDeltaText());
@@ -91,6 +99,57 @@ class DashboardViewTest extends SpringBrowserlessTest {
assertNotNull($view(PieChart.class).first()); 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 @Test
void addAndCloseWidget_changesItemCount() { void addAndCloseWidget_changesItemCount() {
navigate(DashboardView.class); navigate(DashboardView.class);
@@ -228,6 +287,15 @@ class DashboardViewTest extends SpringBrowserlessTest {
$(Button.class).from(picker).withText(translate(titleKey)).first().click(); $(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) { private GridStackItem itemById(String id) {
return $view(GridStackItem.class) return $view(GridStackItem.class)
.withCondition(item -> id.equals(item.getItemId())).single(); .withCondition(item -> id.equals(item.getItemId())).single();
@@ -237,8 +305,8 @@ class DashboardViewTest extends SpringBrowserlessTest {
return $view(Button.class).withText(translate(translationKey)).first(); return $view(Button.class).withText(translate(translationKey)).first();
} }
private String translate(String translationKey) { private String translate(String translationKey, Object... params) {
return getCurrentView().getElement().getComponent() return getCurrentView().getElement().getComponent()
.orElseThrow().getTranslation(translationKey); .orElseThrow().getTranslation(translationKey, params);
} }
} }
@@ -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<DashboardFilter> 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());
}
}
@@ -38,7 +38,7 @@ class WidgetRegistryTest {
void register_replacesTheDefinitionOfTheSameType() { void register_replacesTheDefinitionOfTheSameType() {
int before = registry.definitions().size(); int before = registry.definitions().size();
WidgetDefinition replacement = new WidgetDefinition(WidgetRegistry.REVENUE_TREND, WidgetDefinition replacement = new WidgetDefinition(WidgetRegistry.REVENUE_TREND,
"card.revenueTrend", 2, 2, () -> null); "card.revenueTrend", 2, 2, context -> null);
registry.register(replacement); registry.register(replacement);