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
@@ -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);
}