Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8f7408f1 | |||
| 7a4bc72799 |
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())));
|
||||
|
||||
|
||||
@@ -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(',');
|
||||
}""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user