Compare commits

...

6 Commits

Author SHA1 Message Date
pitfriedrich 0a8f7408f1 Merge pull request 'fix: responsive grid breakpoints for small screens (#41)' (#50) from ai/issue-41-responsive-grid-breakpoints into main
Reviewed-on: #50
2026-07-29 05:56:55 +00:00
Pit Friedrich 7a4bc72799 fix: responsive grid breakpoints for small screens (#41)
CI / build-and-test (pull_request) Successful in 2m47s
Add a columnOpts breakpoint (768px -> 1 column) so widgets stack
full-width on mobile instead of staying at 12 columns. Persisting to
localStorage is skipped while gridstack's own responsive collapse is
active, so narrowing the window can no longer overwrite the saved
desktop layout; drag/resize is disabled below the same breakpoint to
avoid touch misfires.
2026-07-29 07:52:36 +02:00
pitfriedrich 505ad32d4a 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
2026-07-29 05:40:11 +00:00
Pit Friedrich dbe304e61e 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.
2026-07-29 07:32:11 +02:00
pitfriedrich 114f00523b Merge pull request 'fix: undo closing a widget (#40)' (#48) from ai/issue-40-undo-closing-a-widget into main
Reviewed-on: #48
2026-07-28 21:30:53 +00:00
Pit Friedrich 4878a7fc06 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
2026-07-28 23:25:13 +02:00
11 changed files with 551 additions and 16 deletions
@@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement {
private observer?: MutationObserver;
private storageKey: string | null = null;
private persistTimer?: ReturnType<typeof setTimeout>;
/** The column count configured server-side (before any responsive
* collapse) — {@link persist} compares the live column count against
* this to tell a real layout change from gridstack's own breakpoint
* collapse. */
private fullColumn = 12;
private mobileQuery?: MediaQueryList;
private mobileQueryHandler?: (e: MediaQueryListEvent) => void;
connectedCallback() {
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
@@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement {
disconnectedCallback() {
this.observer?.disconnect();
this.observer = undefined;
if (this.mobileQuery && this.mobileQueryHandler) {
this.mobileQuery.removeEventListener('change', this.mobileQueryHandler);
}
this.mobileQuery = undefined;
this.mobileQueryHandler = undefined;
// Write out a still-pending debounced save instead of dropping it:
// a drag/resize followed straight away by navigating to another route
// detaches this element inside the debounce window, which used to lose
@@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement {
this.classList.add('grid-stack');
const options = JSON.parse(optionsJson);
this.fullColumn = options.column ?? this.fullColumn;
const grid = GridStack.init(options, this);
if (!grid) return;
this.grid = grid;
@@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement {
grid.on('change added removed', () => this.schedulePersist());
grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
// Below the responsive breakpoint, disable drag/resize: rearranging a
// stacked mobile layout by touch is mostly misfires, and it's the
// desktop layout — not the stacked one — that's worth protecting from
// an accidental drag. Mirrors the same width gridstack's own
// columnOpts.breakpoints collapses at, so both switch together.
const breakpointWidth: number | undefined = options.columnOpts?.breakpoints?.[0]?.w;
if (breakpointWidth) {
this.mobileQuery = window.matchMedia(`(max-width: ${breakpointWidth}px)`);
this.mobileQueryHandler = (e) => this.grid?.setStatic(e.matches);
this.mobileQuery.addEventListener('change', this.mobileQueryHandler);
this.grid.setStatic(this.mobileQuery.matches);
}
}
/** Drops any saved layout for this grid and re-applies the positions
@@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement {
private persist(notifyServer = true) {
if (!this.grid) return;
// gridstack's responsive columnOpts collapse fires the same 'change'
// event a real drag/resize would, with save() reporting whatever
// shape the engine forced items into at 1 column. That shape is not
// something the user chose — persisting it would silently overwrite
// the desktop layout the next time the window narrows.
if (this.grid.getColumn() !== this.fullColumn) return;
const nodes = this.grid.save(false) as GridStackNode[];
if (this.storageKey) {
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
@@ -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;
}
}
@@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
return this;
}
/**
* Below {@code maxWidthPx} (measured against the browser window, not just
* this element's own width), gridstack collapses to {@code columns}
* columns — pass 1 to stack every item full-width, which is what makes a
* chart legible on a phone. Drag/resize is disabled below the same
* threshold (see {@code grid-stack.ts}): rearranging a stacked mobile
* layout by touch is mostly misfires, and gridstack still restores the
* pre-collapse positions once the window widens back past the threshold —
* that restore is the engine's own column-change cache, not something this
* class drives. Persisting to {@code localStorage} (see
* {@link #setStorageKey(String)}) is suppressed while collapsed, so a
* narrowed window can never overwrite the saved desktop layout.
*/
public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) {
Map<String, Object> breakpoint = new LinkedHashMap<>();
breakpoint.put("w", maxWidthPx);
breakpoint.put("c", columns);
Map<String, Object> columnOpts = new LinkedHashMap<>();
columnOpts.put("breakpointForWindow", true);
columnOpts.put("breakpoints", List.of(breakpoint));
options.put("columnOpts", columnOpts);
return this;
}
/**
* Enables browser-localStorage persistence of the layout under the given
* key (shared across sessions/tabs on the same origin — pick something
@@ -17,8 +17,10 @@ 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;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle;
@@ -40,7 +42,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
@@ -56,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "dashboard";
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
private static final int MOBILE_BREAKPOINT_PX = 768;
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3;
@@ -64,6 +71,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<>();
@@ -76,13 +87,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY);
// Below phone/small-tablet width, stack every widget full-width.
grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
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);
@@ -97,22 +124,61 @@ 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");
/** 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() {
@@ -160,10 +226,60 @@ 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);
updateEmptyState();
}
/**
* 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) {
updateEmptyState();
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);
updateEmptyState();
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
@@ -420,3 +420,32 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
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;
}
@@ -37,6 +37,11 @@ gridstack.restore=Wiederherstellen
gridstack.duplicate=Duplizieren
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
@@ -37,6 +37,11 @@ gridstack.restore=Restore
gridstack.duplicate=Duplicate
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
@@ -37,6 +37,11 @@ gridstack.restore=Restaurar
gridstack.duplicate=Duplicar
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.
@@ -0,0 +1,116 @@
package com.example.e2e;
import com.microsoft.playwright.Locator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* End-to-end coverage for the {@code columnOpts} breakpoint added to
* {@code GridStackLayout} (see {@code DashboardView#MOBILE_BREAKPOINT_PX}):
* gridstack's own column-change caching restores the desktop layout, but only
* a real browser proves the resize actually happens and that the collapsed,
* single-column shape never gets written into the persisted layout.
*/
class ResponsiveGridPlaywrightTest extends PlaywrightTestBase {
private static final int DESKTOP_WIDTH = 1280;
private static final int DESKTOP_HEIGHT = 900;
private static final int MOBILE_WIDTH = 375;
private static final int MOBILE_HEIGHT = 720;
@BeforeEach
void openDashboardAtDesktopWidth() {
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
navigate("");
assertThat(widget()).isVisible();
page.waitForCondition(() -> columns() == 12);
// Let any debounced persist from the initial makeWidget() calls settle
// before a test starts measuring, so it isn't racing that write.
page.waitForTimeout(300);
}
@Test
void mobileViewport_stacksWidgetsFullWidthAndDisablesDragging() {
double gridWidthDesktop = gridWidth();
double itemWidthDesktop = itemWidth();
assertTrue(itemWidthDesktop < gridWidthDesktop - 1,
"at desktop width the widget must not already span the whole grid");
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
page.waitForCondition(() -> columns() == 1);
double gridWidthMobile = gridWidth();
double itemWidthMobile = itemWidth();
assertEquals(gridWidthMobile, itemWidthMobile, 1.0,
"below the breakpoint every widget must span the full grid width");
assertTrue(isStatic(), "dragging/resizing must be disabled below the breakpoint");
}
@Test
void collapsingToMobile_doesNotOverwriteThePersistedDesktopLayout() {
String before = slot(widget());
String storedBefore = storedLayout();
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
page.waitForCondition(() -> columns() == 1);
// give the (debounced) persist path a chance to run, if it were going to
page.waitForTimeout(300);
assertEquals(storedBefore, storedLayout(),
"collapsing to one column must not touch the persisted desktop layout");
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
page.waitForCondition(() -> columns() == 12);
assertEquals(before, slot(widget()), "the original desktop layout must be restored");
assertFalse(isStatic(), "dragging/resizing must be re-enabled back above the breakpoint");
}
private Locator widget() {
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
}
private int columns() {
Object value = page.locator(".grid-stack").evaluate(
"el => el.style.getPropertyValue('--gs-columns')");
return Integer.parseInt(String.valueOf(value));
}
private boolean isStatic() {
return (boolean) page.locator(".grid-stack").evaluate(
"el => el.classList.contains('grid-stack-static')");
}
private double gridWidth() {
return ((Number) page.locator(".grid-stack").evaluate(
"el => el.getBoundingClientRect().width")).doubleValue();
}
private double itemWidth() {
return ((Number) widget().evaluate(
"el => el.getBoundingClientRect().width")).doubleValue();
}
/** The value {@code GridStackLayout} keeps in {@code localStorage} for this
* view, unaffected by the responsive collapse. */
private String storedLayout() {
Object value = page.evaluate("() => localStorage.getItem('gridstack:dashboard')");
return value == null ? null : String.valueOf(value);
}
/** The item's box measured against the grid, not the viewport — same as
* {@code WidgetActionMenuPlaywrightTest#slot}. */
private String slot(Locator item) {
return (String) item.evaluate("""
el => {
const grid = el.closest('.grid-stack').getBoundingClientRect();
const box = el.getBoundingClientRect();
return [box.left - grid.left, box.top - grid.top, box.width, box.height]
.map(Math.round).join(',');
}""");
}
}
@@ -0,0 +1,65 @@
package com.example.e2e;
import com.microsoft.playwright.Locator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* End-to-end coverage for the undo-on-close toast (see
* {@code DashboardView#offerUndo}): a real browser is what actually confirms
* the item lands back in its exact grid cell, gridstack animation included.
*/
class WidgetUndoClosePlaywrightTest extends PlaywrightTestBase {
@BeforeEach
void openDashboard() {
navigate("");
}
@Test
void close_thenUndo_restoresTheExactSlot() {
Locator item = widget();
String before = slot(item);
closeButton(item).click();
assertThat(widget()).not().isAttached();
undoButton().click();
Locator restored = widget();
assertThat(restored).isAttached();
page.waitForCondition(() -> before.equals(slot(restored)));
assertEquals(before, slot(restored), "undo must restore the exact position and size");
}
/** Targeted by its stable {@code gs-id} rather than by position: closing it
* changes which item a positional selector would resolve to first. */
private Locator widget() {
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
}
private Locator closeButton(Locator item) {
return item.locator("> .dialect-close-button");
}
private Locator undoButton() {
return page.locator("vaadin-notification-card").getByRole(
com.microsoft.playwright.options.AriaRole.BUTTON,
new Locator.GetByRoleOptions().setName("Undo"));
}
/** The item's box measured against the grid, not the viewport — same as
* {@code WidgetActionMenuPlaywrightTest#slot}. */
private String slot(Locator item) {
return (String) item.evaluate("""
el => {
const grid = el.closest('.grid-stack').getBoundingClientRect();
const box = el.getBoundingClientRect();
return [box.left - grid.left, box.top - grid.top, box.width, box.height]
.map(Math.round).join(',');
}""");
}
}
@@ -25,6 +25,7 @@ import com.vaadin.flow.component.contextmenu.MenuItem;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test;
@@ -267,6 +268,114 @@ class DashboardViewTest extends SpringBrowserlessTest {
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
}
@Test
void closingAWidget_showsAnUndoToast() {
navigate(DashboardView.class);
clickCloseButton(itemById("revenue-trend"));
Notification toast = $(Notification.class).first();
assertNotNull(toast, "closing a widget must offer a way back");
assertNotNull($(Button.class).from(toast).withText(translate("gridstack.undo")).first());
}
@Test
void undo_restoresTheWidgetAtItsOriginalPositionAndId() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem.Position before = positionOf("revenue-trend");
clickCloseButton(itemById("revenue-trend"));
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
clickUndo();
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
assertEquals(before, positionOf("revenue-trend"),
"undo must restore the exact position and size, not the next free slot");
assertEquals(1, $view(LineChart.class).all().size(), "the chart itself is back too");
}
/** Closing the same widget twice in a row — once, undoing, then again —
* must still offer an undo the second time: the close listener is
* registered once per item, not once per (re-)add. */
@Test
void undoneWidget_offersAnUndoAgainWhenClosedAnotherTime() {
navigate(DashboardView.class);
clickCloseButton(itemById("revenue-trend"));
clickUndo();
clickCloseButton(itemById("revenue-trend"));
assertNotNull($(Button.class).from($(Notification.class).first())
.withText(translate("gridstack.undo")).first());
}
/** A close triggered from code (not the button or the menu) is not a
* misclick, so it must not spawn a toast the user never asked for. */
@Test
void programmaticClose_doesNotShowAnUndoToast() {
navigate(DashboardView.class);
itemById("revenue-trend").close();
assertTrue($(Notification.class).all().isEmpty(),
"a programmatic close must not offer an undo");
}
private void clickUndo() {
$(Button.class).from($(Notification.class).first())
.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);