Files
chart-app/src/main/java/com/example/widgets/WidgetRegistry.java
T
Pit Friedrich df5d1a9b9b
CI / build-and-test (pull_request) Successful in 2m46s
fix: add a global dashboard filter bar (#24)
The reporting period was baked into the data service, so nothing could
change what the dashboard shows without editing code.

- DashboardFilter (period + optional region) parameterises every
  ChartDataService query; the no-arg overloads are the default filter.
- DashboardContext is the bus between the new DashboardFilterBar and the
  widgets: charts subscribe in WidgetRegistry, KPI tiles in DashboardView.
- Updates go through updateData, so widgets patch in place instead of
  being rebuilt; SparklineChart gained the same path.
- KpiData carries the number, the bundle the unit and number pattern, so
  a KPI value can follow the filter and stay locale-formatted.
- Filter state is deliberately not persisted, unlike the grid layout.

Closes #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 20:52:33 +02:00

128 lines
5.5 KiB
Java

package com.example.widgets;
import com.example.components.ApexChart;
import com.example.components.AxisChart;
import com.example.components.BarChart;
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
* the view is what lets the "add widget" picker offer real content and lets a
* 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}, 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 {
public static final String REVENUE_TREND = "revenue-trend";
public static final String REVENUE_MONTH = "revenue-month";
public static final String REVENUE_REGION = "revenue-region";
private final ChartDataService dataService;
/** Insertion-ordered: the picker lists definitions in registration order. */
private final Map<String, WidgetDefinition> definitions = new LinkedHashMap<>();
public WidgetRegistry(ChartDataService dataService) {
this.dataService = dataService;
register(new WidgetDefinition(REVENUE_TREND, "card.revenueTrend", 6, 3,
context -> axisChart(new LineChart(), context)));
register(new WidgetDefinition(REVENUE_MONTH, "card.revenueByMonth", 6, 3,
context -> axisChart(new BarChart(), context)));
register(new WidgetDefinition(REVENUE_REGION, "card.revenueByRegion", 5, 3,
this::pieChart));
}
/** Adds a definition, replacing any earlier one of the same type. */
public final void register(WidgetDefinition definition) {
definitions.put(definition.type(), definition);
}
public List<WidgetDefinition> definitions() {
return List.copyOf(definitions.values());
}
/** The definition for the given type. Unknown types are a programming
* error, not user input — the picker only ever offers registered ones. */
public WidgetDefinition require(String type) {
WidgetDefinition definition = definitions.get(type);
if (definition == null) {
throw new IllegalArgumentException("unknown widget type: " + type);
}
return definition;
}
private Component axisChart(AxisChart chart, DashboardContext context) {
chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
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(DashboardContext context) {
PieChart chart = new PieChart();
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);
}
/** Charts fill their grid item instead of using a fixed pixel height, so
* resizing a widget resizes the chart (grid-stack.ts fires a window
* resize on resizestop, which ApexCharts reflows on). */
private Component sizeFull(ApexChart chart) {
chart.setWidthFull();
chart.setHeight("100%");
return chart;
}
/** Data sets carry translation keys, not display text — see
* {@link ChartSeries}. */
private List<String> translate(Component component, List<String> keys) {
return keys.stream().map(component::getTranslation).toList();
}
}