Merge pull request 'fix: empty state when all dashboard widgets are closed' (#49) from ai/issue-39-empty-state-dashboard into main
Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<GridStackItem> items = $view(GridStackItem.class).all();
|
||||
while (!items.isEmpty()) {
|
||||
clickCloseButton(items.getFirst());
|
||||
items = $view(GridStackItem.class).all();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonClosableItem_hasNoCloseButton() {
|
||||
navigate(DashboardView.class);
|
||||
|
||||
Reference in New Issue
Block a user