fix: empty state when all dashboard widgets are closed (#39)
CI / build-and-test (pull_request) Successful in 2m22s

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.
This commit is contained in:
Pit Friedrich
2026-07-29 07:32:11 +02:00
parent 114f00523b
commit dbe304e61e
6 changed files with 154 additions and 8 deletions
@@ -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<String, KpiTile> 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<KpiData> 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);