fix: make dashboard widgets closable (#14)
CI / build-and-test (pull_request) Successful in 1m27s

Every GridStackItem now renders a close button next to its drag grip,
mirroring the grip's hover affordance. Closing detaches the item
server-side; grid-stack.ts already unregisters widgets via its
MutationObserver, so the layout is persisted without an extra protocol.

Closable is on by default and can be turned off per item with
setClosable(false); GridStackItem.CloseEvent lets views react.

The GridStackView "remove last" toolbar button is dropped — per-widget
close replaces it, so the view no longer tracks a widget stack. Its
counter now only grows, keeping a closed widget's id from being handed
to a new widget (which would inherit the saved position).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq
This commit is contained in:
Pit Friedrich
2026-07-26 20:17:05 +02:00
parent 2e2c9aec3b
commit 7d23eef81b
8 changed files with 185 additions and 37 deletions
@@ -5,13 +5,18 @@ import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.browserless.internal.ElementUtilsKt;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -32,7 +37,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
}
@Test
void addAndRemoveWidget_changesItemCount() {
void addAndCloseWidget_changesItemCount() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
@@ -41,18 +46,59 @@ class GridStackViewTest extends SpringBrowserlessTest {
button("gridstack.addWidget").click();
assertEquals(initial + 1, grid.getLayout().size());
button("gridstack.removeWidget").click();
clickCloseButton($view(GridStackItem.class).last());
assertEquals(initial, grid.getLayout().size());
}
@Test
void removeWidget_onDefaultLayout_keepsStaticWidgets() {
void closeButton_removesTheClickedWidgetOnly() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
button("gridstack.removeWidget").click();
GridStackItem first = $view(GridStackItem.class).first();
String closedId = first.getItemId();
assertEquals(4, grid.getLayout().size(), "static widgets must not be removable");
clickCloseButton(first);
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(3, layout.size());
assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())),
"the closed widget must be gone from the layout");
}
@Test
void closeListener_firesOnceItemIsDetached() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem item = $view(GridStackItem.class).first();
int[] fired = {0};
item.addCloseListener(e -> fired[0]++);
item.close();
assertEquals(1, fired[0]);
assertEquals(3, grid.getLayout().size());
}
@Test
void nonClosableItem_hasNoCloseButton() {
navigate(GridStackView.class);
GridStackItem item = $view(GridStackItem.class).first();
assertTrue(item.isClosable(), "items are closable by default");
item.setClosable(false);
assertFalse(item.isClosable());
assertFalse(item.getChildren().anyMatch(child -> child == item.getCloseButton()));
}
/** Fires the DOM click the close button listens for, rather than calling
* {@code close()} directly, so the button's own wiring is covered too. */
private void clickCloseButton(GridStackItem item) {
Div closeButton = item.getCloseButton();
ElementUtilsKt._fireDomEvent(closeButton.getElement(),
new DomEvent(closeButton.getElement(), "click", JacksonUtils.createObjectNode()));
}
private Button button(String translationKey) {