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
@@ -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