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 where a widget * sits, the registry decides what one is. *

* 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 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 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. *

* 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 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 translate(Component component, List keys) { return keys.stream().map(component::getTranslation).toList(); } }