diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java
index 0c1edf2..c2fa66a 100644
--- a/src/main/java/com/example/views/DashboardView.java
+++ b/src/main/java/com/example/views/DashboardView.java
@@ -1,24 +1,19 @@
package com.example.views;
-import com.example.components.ApexChart;
-import com.example.components.AxisChart;
-import com.example.components.BarChart;
import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
-import com.example.components.LineChart;
-import com.example.components.PieChart;
import com.example.data.ChartDataService;
-import com.example.data.ChartSeries;
import com.example.data.KpiData;
-import com.vaadin.flow.component.Component;
+import com.example.widgets.WidgetDefinition;
+import com.example.widgets.WidgetRegistry;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
-import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle;
@@ -30,11 +25,12 @@ import java.util.List;
* The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose
* layout is persisted per browser, plus controls to add widgets and reset the
* layout at runtime. Widgets are removed by their own close button (see
- * {@link GridStackItem#setClosable(boolean)}), not from the toolbar.
+ * {@link GridStackItem#setClosable(boolean)}), not from the toolbar, and are
+ * added back from the picker over {@link WidgetRegistry}.
*
- * All widget numbers come from {@link ChartDataService}; this view only decides
- * where a widget sits and resolves the data's translation keys against the
- * bundle.
+ * 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.
*/
@Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle {
@@ -44,13 +40,13 @@ 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 GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
private int extraWidgetCount;
- public DashboardView(ChartDataService dataService) {
- this.dataService = dataService;
+ public DashboardView(ChartDataService dataService, WidgetRegistry widgets) {
+ this.widgets = widgets;
addClassName("dialect-content");
grid.setWidthFull();
@@ -70,12 +66,9 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
}
grid.add(
- new GridStackItem("revenue-trend", 0, 1, 6, 3,
- new Card(getTranslation("card.revenueTrend"), lineChart())),
- new GridStackItem("revenue-month", 6, 1, 6, 3,
- new Card(getTranslation("card.revenueByMonth"), barChart())),
- new GridStackItem("revenue-region", 0, 4, 5, 3,
- new Card(getTranslation("card.revenueByRegion"), pieChart())),
+ defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
+ defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
+ defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
new GridStackItem("hint", 5, 4, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))));
@@ -88,7 +81,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private HorizontalLayout toolbar() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
- e -> addWidget());
+ e -> openWidgetPicker());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
@@ -101,16 +94,52 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
return toolbar;
}
- private void addWidget() {
+ /** Lists the registered widget types; picking one adds a widget of that
+ * type, which is also how a closed widget is brought back. */
+ private void openWidgetPicker() {
+ Dialog picker = new Dialog(getTranslation("gridstack.pickerTitle"));
+
+ VerticalLayout choices = new VerticalLayout();
+ choices.setPadding(false);
+ for (WidgetDefinition definition : widgets.definitions()) {
+ Button choice = new Button(getTranslation(definition.titleKey()), e -> {
+ addWidget(definition);
+ picker.close();
+ });
+ choice.setWidthFull();
+ choices.add(choice);
+ }
+
+ picker.add(choices);
+ picker.getFooter().add(new Button(getTranslation("form.cancel"), e -> picker.close()));
+ picker.open();
+ }
+
+ /** Adds a widget at the top left in its type's default size; gridstack
+ * floats it down into the first free slot. */
+ private void addWidget(WidgetDefinition definition) {
// The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID.
- // The counter only ever grows: closing a widget must not hand its id to
- // the next one, or the new widget would inherit the closed one's saved
- // position.
+ // The counter only ever grows, and it is shared across types: closing a
+ // widget must not hand its id to the next one, or the new widget would
+ // inherit the closed one's saved position.
extraWidgetCount++;
- String title = getTranslation("gridstack.widget", extraWidgetCount);
- grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
- new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
+ grid.add(widget(definition.type() + "-" + extraWidgetCount, definition,
+ 0, 0, definition.width(), definition.height()));
+ }
+
+ /** A widget of the initial set. Its id is the plain type — never handed out
+ * again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
+ * counter — and it keeps the dashboard's own placement rather than the
+ * type's default size. */
+ private GridStackItem defaultWidget(String type, int x, int y, int w, int h) {
+ return widget(type, widgets.require(type), x, y, w, h);
+ }
+
+ 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()));
}
/** The displayed value comes from the bundle alongside the label, since it
@@ -123,48 +152,6 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
.setSparkline(label, kpi.trend());
}
- private Component lineChart() {
- return axisChart(new LineChart());
- }
-
- private Component barChart() {
- return axisChart(new BarChart());
- }
-
- private Component axisChart(AxisChart chart) {
- ChartSeries series = dataService.revenueByMonth();
- chart.setData(getTranslation(series.nameKey()), series.values(),
- translate(series.categoryKeys()));
- chart.addPointClickListener(e -> Notification.show(getTranslation(
- "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
- return sizeFull(chart);
- }
-
- private Component pieChart() {
- ChartSeries series = dataService.revenueByRegion();
- PieChart chart = new PieChart();
- // A pie has no series name — its categories are the slice labels.
- chart.setData(series.values(), translate(series.categoryKeys()));
- chart.addPointClickListener(e -> Notification.show(
- getTranslation("chart.sliceClick", e.getDataPointIndex())));
- 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(List keys) {
- return keys.stream().map(this::getTranslation).toList();
- }
-
@Override
public String getPageTitle() {
return getTranslation("page.dashboard");
diff --git a/src/main/java/com/example/widgets/WidgetDefinition.java b/src/main/java/com/example/widgets/WidgetDefinition.java
new file mode 100644
index 0000000..d3dcbc6
--- /dev/null
+++ b/src/main/java/com/example/widgets/WidgetDefinition.java
@@ -0,0 +1,23 @@
+package com.example.widgets;
+
+import com.vaadin.flow.component.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * One kind of dashboard widget: what it is called, how large it starts out, and
+ * how to build its content. Definitions live in {@link WidgetRegistry}; the
+ * dashboard turns one into a grid item, so a definition knows nothing about
+ * gridstack or about where its widget ends up sitting.
+ *
+ * @param type stable id of the widget kind — grid item ids are derived from
+ * it, so it must not change once a layout has been persisted
+ * @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
+ */
+public record WidgetDefinition(String type, String titleKey, int width, int height,
+ Supplier factory) {
+}
diff --git a/src/main/java/com/example/widgets/WidgetRegistry.java b/src/main/java/com/example/widgets/WidgetRegistry.java
new file mode 100644
index 0000000..cf284dc
--- /dev/null
+++ b/src/main/java/com/example/widgets/WidgetRegistry.java
@@ -0,0 +1,102 @@
+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.vaadin.flow.component.Component;
+import com.vaadin.flow.component.notification.Notification;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 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}; 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.
+ */
+@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,
+ () -> axisChart(new LineChart())));
+ register(new WidgetDefinition(REVENUE_MONTH, "card.revenueByMonth", 6, 3,
+ () -> axisChart(new BarChart())));
+ 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) {
+ ChartSeries series = dataService.revenueByMonth();
+ chart.setData(chart.getTranslation(series.nameKey()), series.values(),
+ translate(chart, series.categoryKeys()));
+ chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
+ "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
+ return sizeFull(chart);
+ }
+
+ private Component pieChart() {
+ ChartSeries series = dataService.revenueByRegion();
+ 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 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();
+ }
+}
diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties
index 6674937..5aca9d5 100644
--- a/src/main/resources/vaadin-i18n/translations.properties
+++ b/src/main/resources/vaadin-i18n/translations.properties
@@ -27,8 +27,7 @@ gridstack.addWidget=Widget hinzufügen
gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert – {0} Widgets
gridstack.statusInitial=Layout unverändert
-gridstack.widget=Widget {0}
-gridstack.widgetText=Frei platzierbare Karte.
+gridstack.pickerTitle=Widget auswählen
gridstack.dragHandle=Verschieben
gridstack.close=Schließen
diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties
index 0105b68..3002881 100644
--- a/src/main/resources/vaadin-i18n/translations_en.properties
+++ b/src/main/resources/vaadin-i18n/translations_en.properties
@@ -27,8 +27,7 @@ gridstack.addWidget=Add widget
gridstack.reset=Reset layout
gridstack.status=Layout changed – {0} widgets
gridstack.statusInitial=Layout unchanged
-gridstack.widget=Widget {0}
-gridstack.widgetText=Freely placeable card.
+gridstack.pickerTitle=Choose a widget
gridstack.dragHandle=Move
gridstack.close=Close
diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties
index 40f35f2..3159d38 100644
--- a/src/main/resources/vaadin-i18n/translations_es.properties
+++ b/src/main/resources/vaadin-i18n/translations_es.properties
@@ -27,8 +27,7 @@ gridstack.addWidget=Añadir widget
gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado – {0} widgets
gridstack.statusInitial=Diseño sin cambios
-gridstack.widget=Widget {0}
-gridstack.widgetText=Tarjeta de colocación libre.
+gridstack.pickerTitle=Elegir un widget
gridstack.dragHandle=Mover
gridstack.close=Cerrar
diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java
index b439ff5..5b73878 100644
--- a/src/test/java/com/example/views/DashboardViewTest.java
+++ b/src/test/java/com/example/views/DashboardViewTest.java
@@ -7,14 +7,18 @@ import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
import com.example.components.LineChart;
import com.example.components.PieChart;
+import com.example.widgets.WidgetDefinition;
+import com.example.widgets.WidgetRegistry;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.browserless.internal.ElementUtilsKt;
import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@@ -31,6 +35,9 @@ class DashboardViewTest extends SpringBrowserlessTest {
/** Four KPI tiles plus three charts and the hint card. */
private static final int DEFAULT_WIDGETS = 8;
+ @Autowired
+ private WidgetRegistry registry;
+
@Test
void view_rendersGridWithDefaultWidgets() {
navigate(DashboardView.class);
@@ -92,12 +99,78 @@ class DashboardViewTest extends SpringBrowserlessTest {
int initial = grid.getLayout().size();
button("gridstack.addWidget").click();
+ assertEquals(initial, grid.getLayout().size(),
+ "opening the picker adds nothing on its own");
+
+ pickWidget("card.revenueByRegion");
assertEquals(initial + 1, grid.getLayout().size());
clickCloseButton($view(GridStackItem.class).last());
assertEquals(initial, grid.getLayout().size());
}
+ @Test
+ void addWidget_ofAChosenType_rendersRealContent() {
+ navigate(DashboardView.class);
+
+ button("gridstack.addWidget").click();
+ pickWidget("card.revenueByRegion");
+
+ assertEquals(2, $view(PieChart.class).all().size(),
+ "the added widget renders a chart, not a placeholder");
+ GridStackItem added = $view(GridStackItem.class).last();
+ assertTrue(added.getItemId().startsWith("revenue-region-"),
+ "unexpected id: " + added.getItemId());
+ }
+
+ @Test
+ void closedWidget_canBeAddedAgainWithAFreshId() {
+ navigate(DashboardView.class);
+
+ GridStackLayout grid = $view(GridStackLayout.class).first();
+ clickCloseButton(itemById("revenue-trend"));
+ assertEquals(0, $view(LineChart.class).all().size(), "the line chart is gone");
+
+ button("gridstack.addWidget").click();
+ pickWidget("card.revenueTrend");
+
+ List layout = grid.getLayout();
+ assertEquals(DEFAULT_WIDGETS, layout.size());
+ assertEquals(1, $view(LineChart.class).all().size(), "the line chart is back");
+ // The closed widget's id must never be handed out again, or the new
+ // widget would inherit its persisted position.
+ assertFalse(layout.stream().anyMatch(p -> "revenue-trend".equals(p.id())));
+ assertTrue(layout.stream().anyMatch(p -> p.id().startsWith("revenue-trend-")));
+ }
+
+ @Test
+ void addingTheSameTypeTwice_yieldsDistinctIds() {
+ navigate(DashboardView.class);
+
+ button("gridstack.addWidget").click();
+ pickWidget("card.revenueByMonth");
+ button("gridstack.addWidget").click();
+ pickWidget("card.revenueByMonth");
+
+ List ids = $view(GridStackLayout.class).first().getLayout().stream()
+ .map(GridStackItem.Position::id).toList();
+ assertEquals(ids.size(), ids.stream().distinct().count(), "duplicate ids: " + ids);
+ }
+
+ @Test
+ void picker_listsEveryRegisteredWidgetType() {
+ navigate(DashboardView.class);
+
+ button("gridstack.addWidget").click();
+
+ Dialog picker = $(Dialog.class).first();
+ for (WidgetDefinition definition : registry.definitions()) {
+ assertNotNull($(Button.class).from(picker)
+ .withText(translate(definition.titleKey())).first(),
+ "picker is missing " + definition.type());
+ }
+ }
+
@Test
void closeButton_removesTheClickedWidgetOnly() {
navigate(DashboardView.class);
@@ -149,6 +222,17 @@ class DashboardViewTest extends SpringBrowserlessTest {
new DomEvent(closeButton.getElement(), "click", JacksonUtils.createObjectNode()));
}
+ /** Clicks a widget type in the picker dialog "add widget" opened. */
+ private void pickWidget(String titleKey) {
+ Dialog picker = $(Dialog.class).first();
+ $(Button.class).from(picker).withText(translate(titleKey)).first().click();
+ }
+
+ private GridStackItem itemById(String id) {
+ return $view(GridStackItem.class)
+ .withCondition(item -> id.equals(item.getItemId())).single();
+ }
+
private Button button(String translationKey) {
return $view(Button.class).withText(translate(translationKey)).first();
}
diff --git a/src/test/java/com/example/widgets/WidgetRegistryTest.java b/src/test/java/com/example/widgets/WidgetRegistryTest.java
new file mode 100644
index 0000000..2f8e51f
--- /dev/null
+++ b/src/test/java/com/example/widgets/WidgetRegistryTest.java
@@ -0,0 +1,48 @@
+package com.example.widgets;
+
+import com.example.data.InMemoryChartDataService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The definitions themselves — the factories need a Vaadin UI to resolve
+ * translations, so they are exercised from {@code DashboardViewTest} instead.
+ */
+class WidgetRegistryTest {
+
+ private final WidgetRegistry registry = new WidgetRegistry(new InMemoryChartDataService());
+
+ @Test
+ void definitions_areListedInRegistrationOrder() {
+ List types = registry.definitions().stream()
+ .map(WidgetDefinition::type).toList();
+
+ assertEquals(List.of(WidgetRegistry.REVENUE_TREND, WidgetRegistry.REVENUE_MONTH,
+ WidgetRegistry.REVENUE_REGION), types);
+ assertTrue(registry.definitions().stream()
+ .allMatch(d -> d.width() > 0 && d.height() > 0),
+ "every definition carries a usable default size");
+ }
+
+ @Test
+ void require_rejectsAnUnknownType() {
+ assertThrows(IllegalArgumentException.class, () -> registry.require("nope"));
+ }
+
+ @Test
+ void register_replacesTheDefinitionOfTheSameType() {
+ int before = registry.definitions().size();
+ WidgetDefinition replacement = new WidgetDefinition(WidgetRegistry.REVENUE_TREND,
+ "card.revenueTrend", 2, 2, () -> null);
+
+ registry.register(replacement);
+
+ assertEquals(before, registry.definitions().size());
+ assertEquals(replacement, registry.require(WidgetRegistry.REVENUE_TREND));
+ }
+}