fix: add a global dashboard filter bar (#24)
CI / build-and-test (pull_request) Successful in 2m46s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
Pit Friedrich
2026-07-28 20:52:33 +02:00
parent cdbd61aefb
commit df5d1a9b9b
19 changed files with 762 additions and 84 deletions
@@ -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<Double> 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;
}
@@ -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<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)
* and so the data logic is testable without a Vaadin component tree.
* <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
* ({@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<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;
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.
* <p>
* 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<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(
"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<Double> 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<Double> 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<KpiData> 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<Double> 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<Double> 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<Double> 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<KpiData> kpis() {
return KPIS;
public List<KpiData> 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<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;
/**
* 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.
* <p>
* {@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"} <em>formatted with</em>
* {@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 {
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";
}
@@ -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.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.
* <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("")
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<String, KpiTile> 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<KpiData> kpis = dataService.kpis();
List<KpiData> 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
@@ -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 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<Component> factory) {
Function<DashboardContext, Component> factory) {
}
@@ -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 <em>where</em> a widget
* sits, the registry decides what one <em>is</em>.
* <p>
* 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.
* <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);
}