fix: empty state when all dashboard widgets are closed #49

Merged
pitfriedrich merged 1 commits from ai/issue-39-empty-state-dashboard into main 2026-07-29 05:40:12 +00:00
6 changed files with 154 additions and 8 deletions
Showing only changes of commit dbe304e61e - Show all commits
@@ -17,6 +17,7 @@ import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog; 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.Paragraph;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification; 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 DashboardContext context = new DashboardContext();
private final GridStackLayout grid = new GridStackLayout(); private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span(); 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 /** 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. */ * data of the same KPI rather than by position. */
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>(); private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
@@ -81,10 +86,24 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
grid.addLayoutChangeListener(e -> status.setText( grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size()))); getTranslation("gridstack.status", e.getPositions().size())));
// KPI tiles first: the numbers a dashboard is read for, above the charts configureEmptyState();
// that explain them. They are 3x1 — a quarter row each, one cell high, buildDefaultWidgets();
// laid out left to right in the order the service returns them. The context.addFilterChangeListener(this::updateKpiTiles);
// grid id is the KPI's own id, so it survives reordering.
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<KpiData> kpis = dataService.kpis(context.getFilter()); List<KpiData> kpis = dataService.kpis(context.getFilter());
for (int i = 0; i < kpis.size(); i++) { for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i); KpiData kpi = kpis.get(i);
@@ -101,7 +120,6 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
}); });
addWidgetToGrid(item); addWidgetToGrid(item);
} }
context.addFilterChangeListener(this::updateKpiTiles);
List.of( List.of(
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3), 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 Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint"))))) new Paragraph(getTranslation("gridstack.hint")))))
.forEach(this::addWidgetToGrid); .forEach(this::addWidgetToGrid);
}
status.setText(getTranslation("gridstack.statusInitial")); /** Builds the placeholder shown once every widget has been closed: a short
status.addClassName("dialect-muted"); * 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() { private HorizontalLayout toolbar() {
@@ -174,6 +231,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private void addWidgetToGrid(GridStackItem item) { private void addWidgetToGrid(GridStackItem item) {
item.addCloseListener(this::offerUndo); item.addCloseListener(this::offerUndo);
grid.add(item); grid.add(item);
updateEmptyState();
} }
/** /**
@@ -190,6 +248,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
* in the grid's next free slot. * in the grid's next free slot.
*/ */
private void offerUndo(GridStackItem.CloseEvent event) { private void offerUndo(GridStackItem.CloseEvent event) {
updateEmptyState();
if (!event.isFromClient()) { if (!event.isFromClient()) {
return; return;
} }
@@ -203,6 +262,7 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
Button undo = new Button(getTranslation("gridstack.undo"), e -> { Button undo = new Button(getTranslation("gridstack.undo"), e -> {
item.setPosition(position.x(), position.y(), position.w(), position.h()); item.setPosition(position.x(), position.y(), position.w(), position.h());
grid.add(item); grid.add(item);
updateEmptyState();
toast.close(); toast.close();
}); });
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
@@ -420,3 +420,32 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
border: 2px dashed var(--dialect-border); border: 2px dashed var(--dialect-border);
border-radius: var(--dialect-radius); 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;
}
@@ -39,6 +39,9 @@ gridstack.remove=Entfernen
gridstack.export=Als CSV exportieren gridstack.export=Als CSV exportieren
gridstack.closed=Widget entfernt gridstack.closed=Widget entfernt
gridstack.undo=Rückgängig 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. # Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen.
export.category=Kategorie export.category=Kategorie
@@ -39,6 +39,9 @@ gridstack.remove=Remove
gridstack.export=Export as CSV gridstack.export=Export as CSV
gridstack.closed=Widget removed gridstack.closed=Widget removed
gridstack.undo=Undo 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. # Column header of the CSV exports; the value column carries the series name.
export.category=Category export.category=Category
@@ -39,6 +39,9 @@ gridstack.remove=Eliminar
gridstack.export=Exportar como CSV gridstack.export=Exportar como CSV
gridstack.closed=Widget eliminado gridstack.closed=Widget eliminado
gridstack.undo=Deshacer 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 # Encabezado de columna de las exportaciones CSV; la columna de valores lleva
# el nombre de la serie. # el nombre de la serie.
@@ -328,6 +328,54 @@ class DashboardViewTest extends SpringBrowserlessTest {
.withText(translate("gridstack.undo")).first().click(); .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<GridStackItem> items = $view(GridStackItem.class).all();
while (!items.isEmpty()) {
clickCloseButton(items.getFirst());
items = $view(GridStackItem.class).all();
}
}
@Test @Test
void nonClosableItem_hasNoCloseButton() { void nonClosableItem_hasNoCloseButton() {
navigate(DashboardView.class); navigate(DashboardView.class);