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