diff --git a/src/main/frontend/components/grid-stack.ts b/src/main/frontend/components/grid-stack.ts index 554d29f..9890713 100644 --- a/src/main/frontend/components/grid-stack.ts +++ b/src/main/frontend/components/grid-stack.ts @@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement { private observer?: MutationObserver; private storageKey: string | null = null; private persistTimer?: ReturnType; + /** 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)); diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java index d66deee..b4bbf42 100644 --- a/src/main/java/com/example/components/GridStackLayout.java +++ b/src/main/java/com/example/components/GridStackLayout.java @@ -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 breakpoint = new LinkedHashMap<>(); + breakpoint.put("w", maxWidthPx); + breakpoint.put("c", columns); + Map 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 diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 0dbf430..83d9e3e 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -59,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; @@ -83,6 +87,8 @@ 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()))); diff --git a/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java b/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java new file mode 100644 index 0000000..6d4410b --- /dev/null +++ b/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java @@ -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(','); + }"""); + } +}