From dbe304e61e30a9581acf28f78bc2ba77335e0db7 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Wed, 29 Jul 2026 07:32:11 +0200 Subject: [PATCH] fix: empty state when all dashboard widgets are closed (#39) Show a placeholder (CTA to add a widget, secondary action to restore the default layout) instead of a blank grid once every widget has been closed. --- .../java/com/example/views/DashboardView.java | 76 +++++++++++++++++-- .../resources/META-INF/resources/styles.css | 29 +++++++ .../vaadin-i18n/translations.properties | 3 + .../vaadin-i18n/translations_en.properties | 3 + .../vaadin-i18n/translations_es.properties | 3 + .../com/example/views/DashboardViewTest.java | 48 ++++++++++++ 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index d541590..0dbf430 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -17,6 +17,7 @@ import com.vaadin.flow.component.Component; 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.Div; import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.notification.Notification; @@ -66,6 +67,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { private final DashboardContext context = new DashboardContext(); private final GridStackLayout grid = new GridStackLayout(); private final Span status = new Span(); + /** Shown instead of {@link #grid} once every widget has been closed; not a + * {@link GridStackItem} itself, so it never becomes draggable and never + * shows up in {@link GridStackLayout#getLayout()}. */ + private final Div emptyState = new Div(); /** 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 kpiTiles = new LinkedHashMap<>(); @@ -81,10 +86,24 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { grid.addLayoutChangeListener(e -> status.setText( getTranslation("gridstack.status", e.getPositions().size()))); - // KPI tiles first: the numbers a dashboard is read for, above the charts - // 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. + configureEmptyState(); + buildDefaultWidgets(); + context.addFilterChangeListener(this::updateKpiTiles); + + status.setText(getTranslation("gridstack.statusInitial")); + status.addClassName("dialect-muted"); + + add(toolbar(), new DashboardFilterBar(context), grid, emptyState); + } + + /** The dashboard's initial widget set: the KPI tiles first — the numbers a + * dashboard is read for, above the charts that explain them — then the + * three default charts and the usage hint. Also used to rebuild the + * dashboard from scratch via {@link #restoreDefaultWidgets()}. */ + private void buildDefaultWidgets() { + // 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 kpis = dataService.kpis(context.getFilter()); for (int i = 0; i < kpis.size(); i++) { KpiData kpi = kpis.get(i); @@ -101,7 +120,6 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { }); addWidgetToGrid(item); } - context.addFilterChangeListener(this::updateKpiTiles); List.of( defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3), @@ -111,11 +129,50 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { new Card(getTranslation("card.gridstackHint"), new Paragraph(getTranslation("gridstack.hint"))))) .forEach(this::addWidgetToGrid); + } - status.setText(getTranslation("gridstack.statusInitial")); - status.addClassName("dialect-muted"); + /** Builds the placeholder shown once every widget has been closed: a short + * explanation, a CTA that opens the same {@link #openWidgetPicker() widget + * picker} as the toolbar, and a secondary action that rebuilds the default + * layout — {@link GridStackLayout#resetLayout()} alone cannot do that here, + * since it only repositions widgets still present, and none are left. */ + private void configureEmptyState() { + emptyState.addClassName("dialect-empty-state"); - add(toolbar(), new DashboardFilterBar(context), grid); + var icon = Fa.GRID.create(); + icon.addClassName("dialect-empty-state__icon"); + + Span title = new Span(getTranslation("gridstack.emptyTitle")); + title.addClassName("dialect-empty-state__title"); + Span hint = new Span(getTranslation("gridstack.emptyHint")); + hint.addClassName("dialect-muted"); + + Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(), + e -> openWidgetPicker()); + add.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + Button restore = new Button(getTranslation("gridstack.restoreDefaults"), Fa.RESET.create(), + e -> restoreDefaultWidgets()); + + HorizontalLayout actions = new HorizontalLayout(add, restore); + actions.addClassName("dialect-empty-state__actions"); + + emptyState.add(icon, title, hint, actions); + } + + /** Clears the stale KPI tile references and rebuilds the initial widget set + * — the empty state's secondary action. */ + private void restoreDefaultWidgets() { + kpiTiles.clear(); + buildDefaultWidgets(); + grid.resetLayout(); + } + + /** Toggles {@link #grid} and {@link #emptyState} based on whether any + * widget is left — called from every path that adds or removes one. */ + private void updateEmptyState() { + boolean empty = grid.getLayout().isEmpty(); + emptyState.setVisible(empty); + grid.setVisible(!empty); } private HorizontalLayout toolbar() { @@ -174,6 +231,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { private void addWidgetToGrid(GridStackItem item) { item.addCloseListener(this::offerUndo); grid.add(item); + updateEmptyState(); } /** @@ -190,6 +248,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { * in the grid's next free slot. */ private void offerUndo(GridStackItem.CloseEvent event) { + updateEmptyState(); if (!event.isFromClient()) { return; } @@ -203,6 +262,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { Button undo = new Button(getTranslation("gridstack.undo"), e -> { item.setPosition(position.x(), position.y(), position.w(), position.h()); grid.add(item); + updateEmptyState(); toast.close(); }); undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index b381244..ed47f1f 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -419,4 +419,33 @@ apex-chart.dialect-sparkline .apexcharts-xaxis { background: var(--dialect-bg); border: 2px dashed var(--dialect-border); border-radius: var(--dialect-radius); +} + +/* DashboardView's placeholder for a widget-less dashboard (not a + GridStackItem, see DashboardView.emptyState) — dashed like gridstack's own + drop placeholder above, to read as "nothing here yet" rather than a card. */ +.dialect-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 48px 24px; + text-align: center; + border: 2px dashed var(--dialect-border); + border-radius: var(--dialect-radius); + color: var(--dialect-ink); +} + +.dialect-empty-state__icon { + font-size: 2rem; + color: var(--dialect-primary); + opacity: 0.8; +} + +.dialect-empty-state__title { + font-weight: 600; +} + +.dialect-empty-state__actions { + gap: 8px; } \ No newline at end of file diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 21216eb..b48e1cd 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -39,6 +39,9 @@ gridstack.remove=Entfernen gridstack.export=Als CSV exportieren gridstack.closed=Widget entfernt gridstack.undo=Rückgängig +gridstack.emptyTitle=Keine Widgets auf dem Dashboard +gridstack.emptyHint=Alle Widgets wurden geschlossen. Füge eines hinzu oder stelle das Standardlayout wieder her. +gridstack.restoreDefaults=Standardlayout wiederherstellen # Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen. export.category=Kategorie diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index ac6f1f6..dd57c1b 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -39,6 +39,9 @@ gridstack.remove=Remove gridstack.export=Export as CSV gridstack.closed=Widget removed gridstack.undo=Undo +gridstack.emptyTitle=No widgets on the dashboard +gridstack.emptyHint=All widgets have been closed. Add one or restore the default layout. +gridstack.restoreDefaults=Restore default layout # Column header of the CSV exports; the value column carries the series name. export.category=Category diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index 181bbf2..ba2328d 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -39,6 +39,9 @@ gridstack.remove=Eliminar gridstack.export=Exportar como CSV gridstack.closed=Widget eliminado gridstack.undo=Deshacer +gridstack.emptyTitle=No hay widgets en el panel +gridstack.emptyHint=Se han cerrado todos los widgets. Añade uno o restablece el diseño predeterminado. +gridstack.restoreDefaults=Restablecer diseño predeterminado # Encabezado de columna de las exportaciones CSV; la columna de valores lleva # el nombre de la serie. diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java index 6643b51..8cce75e 100644 --- a/src/test/java/com/example/views/DashboardViewTest.java +++ b/src/test/java/com/example/views/DashboardViewTest.java @@ -328,6 +328,54 @@ class DashboardViewTest extends SpringBrowserlessTest { .withText(translate("gridstack.undo")).first().click(); } + @Test + void closingEveryWidget_showsEmptyStateAndHidesTheGrid() { + navigate(DashboardView.class); + GridStackLayout grid = $view(GridStackLayout.class).first(); + + closeAllWidgets(); + + assertTrue(grid.getLayout().isEmpty(), "no widget should be left"); + assertFalse(grid.isVisible(), "the empty grid must not show as an empty box"); + Div emptyState = $view(Div.class).withClassName("dialect-empty-state").first(); + assertTrue(emptyState.isVisible(), "the empty state must appear"); + } + + @Test + void addingAWidget_hidesTheEmptyStateAgain() { + navigate(DashboardView.class); + closeAllWidgets(); + + button("gridstack.addWidget").click(); + pickWidget("card.revenueByRegion"); + + assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(), + "the empty state must be gone once a widget is back — invisible components drop out of the query"); + assertTrue($view(GridStackLayout.class).first().isVisible()); + } + + @Test + void restoreDefaultLayout_rebuildsTheDefaultWidgetsFromTheEmptyState() { + navigate(DashboardView.class); + closeAllWidgets(); + + button("gridstack.restoreDefaults").click(); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + assertEquals(DEFAULT_WIDGETS, grid.getLayout().size()); + assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(), + "the empty state must be gone once the default layout is back"); + assertTrue(grid.isVisible()); + } + + private void closeAllWidgets() { + List items = $view(GridStackItem.class).all(); + while (!items.isEmpty()) { + clickCloseButton(items.getFirst()); + items = $view(GridStackItem.class).all(); + } + } + @Test void nonClosableItem_hasNoCloseButton() { navigate(DashboardView.class); -- 2.52.0