fix: undo closing a widget (#40)
CI / build-and-test (pull_request) Successful in 2m19s

Closing a widget was instant and destructive - one misclick on the X
lost its size and position. Close now shows a toast with an undo
button that re-adds the very same widget at its captured gs-x/y/w/h,
keeping its gs-id. Programmatic close() (fromClient=false) is skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGRChzremwYCUctf2qMrQz
This commit is contained in:
Pit Friedrich
2026-07-28 23:25:13 +02:00
parent cd1d8f494d
commit 4878a7fc06
7 changed files with 219 additions and 8 deletions
@@ -312,10 +312,26 @@ public class GridStackItem extends Div {
}
private void close(boolean fromClient) {
// Captured before detaching: once removeFromParent() runs, the grid no
// longer reports this item, so its position could not be read back from
// there afterwards. The gs-* attributes themselves are untouched by the
// detach, so a caller that keeps this item around and re-adds it later
// (e.g. to undo the close) lands it back at exactly this spot.
Position position = capturePosition();
// Detach first so listeners observe the layout they are about to see —
// getLayout() on the grid no longer counts this item.
getElement().removeFromParent();
fireEvent(new CloseEvent(this, fromClient));
fireEvent(new CloseEvent(this, fromClient, position));
}
private Position capturePosition() {
return new Position(getItemId(),
attrInt("gs-x"), attrInt("gs-y"), attrInt("gs-w"), attrInt("gs-h"));
}
private int attrInt(String name) {
String value = getElement().getAttribute(name);
return value == null ? 0 : Integer.parseInt(value);
}
/** Shows or hides the close button (shown by default). */
@@ -346,10 +362,23 @@ public class GridStackItem extends Div {
return addListener(CloseEvent.class, listener);
}
/** Fired after the item has been removed from its {@link GridStackLayout}. */
/** Fired after the item has been removed from its {@link GridStackLayout}.
* Carries the {@link Position} it held right before detaching, so a
* listener can offer to undo the close (re-adding the item at that exact
* spot) without having to track positions itself. {@link #isFromClient()}
* tells apart a user-initiated close (button/menu) from a programmatic
* {@link GridStackItem#close()} — an undo affordance only makes sense for
* the former. */
public static class CloseEvent extends ComponentEvent<GridStackItem> {
CloseEvent(GridStackItem source, boolean fromClient) {
private final Position position;
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
super(source, fromClient);
this.position = position;
}
public Position getPosition() {
return position;
}
}
@@ -19,6 +19,7 @@ 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;
@@ -40,7 +41,8 @@ import java.util.Optional;
* 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, and are
* added back from the picker over {@link WidgetRegistry}.
* added back from the picker over {@link WidgetRegistry} — or undone straight
* from the toast every close spawns (see {@link #offerUndo}).
* <p>
* 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
@@ -97,17 +99,18 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
refreshKpiTile(kpi.id());
}
});
grid.add(item);
addWidgetToGrid(item);
}
context.addFilterChangeListener(this::updateKpiTiles);
grid.add(
List.of(
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")))));
new Paragraph(getTranslation("gridstack.hint")))))
.forEach(this::addWidgetToGrid);
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
@@ -160,10 +163,57 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
// widget must not hand its id to the next one, or the new widget would
// inherit the closed one's saved position.
extraWidgetCount++;
grid.add(widget(definition.type() + "-" + extraWidgetCount, definition,
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
0, 0, definition.width(), definition.height()));
}
/** Adds an item to the grid and wires the undo toast onto its close — every
* widget goes through here exactly once, so the close listener (and thus
* the undo offer) is registered once per item, not once per re-add: an
* undo hands the very same instance back to {@link #grid} directly. */
private void addWidgetToGrid(GridStackItem item) {
item.addCloseListener(this::offerUndo);
grid.add(item);
}
/**
* Closing a widget is one misclick away from losing its position, and a
* confirmation dialog on every close would be worse than the problem — so
* this shows a dismissible undo toast instead. Skipped for programmatic
* closes ({@link GridStackItem#close()}, e.g. from duplicate cleanup),
* which is exactly what {@link GridStackItem.CloseEvent#isFromClient()}
* tells apart.
* <p>
* Undo re-adds the very item that was closed — not a rebuilt copy — at the
* {@link GridStackItem.Position} the event captured before detaching, so it
* keeps its {@code gs-id} and its exact size/position rather than landing
* in the grid's next free slot.
*/
private void offerUndo(GridStackItem.CloseEvent event) {
if (!event.isFromClient()) {
return;
}
GridStackItem item = event.getSource();
GridStackItem.Position position = event.getPosition();
Notification toast = new Notification();
toast.setDuration(8000);
toast.setPosition(Notification.Position.BOTTOM_START);
Button undo = new Button(getTranslation("gridstack.undo"), e -> {
item.setPosition(position.x(), position.y(), position.w(), position.h());
grid.add(item);
toast.close();
});
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
HorizontalLayout content = new HorizontalLayout(
new Span(getTranslation("gridstack.closed")), undo);
content.setAlignItems(Alignment.CENTER);
toast.add(content);
toast.open();
}
/** 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
@@ -37,6 +37,8 @@ gridstack.restore=Wiederherstellen
gridstack.duplicate=Duplizieren
gridstack.remove=Entfernen
gridstack.export=Als CSV exportieren
gridstack.closed=Widget entfernt
gridstack.undo=Rückgängig
# Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen.
export.category=Kategorie
@@ -37,6 +37,8 @@ gridstack.restore=Restore
gridstack.duplicate=Duplicate
gridstack.remove=Remove
gridstack.export=Export as CSV
gridstack.closed=Widget removed
gridstack.undo=Undo
# Column header of the CSV exports; the value column carries the series name.
export.category=Category
@@ -37,6 +37,8 @@ gridstack.restore=Restaurar
gridstack.duplicate=Duplicar
gridstack.remove=Eliminar
gridstack.export=Exportar como CSV
gridstack.closed=Widget eliminado
gridstack.undo=Deshacer
# Encabezado de columna de las exportaciones CSV; la columna de valores lleva
# el nombre de la serie.